From 05af7e55b308aeab4e9afdfdede09e827d3114d5 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 10:36:23 +0200 Subject: [PATCH 1/7] 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. --- docs/update-manager.md | 221 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 docs/update-manager.md diff --git a/docs/update-manager.md b/docs/update-manager.md new file mode 100644 index 00000000..b5684f36 --- /dev/null +++ b/docs/update-manager.md @@ -0,0 +1,221 @@ +# 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// │ +│ ├─ 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//` into `staging/` 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 + +The Windows filesystem does not allow replacing a currently-running executable. Two common patterns: + +- **Rename-before-replace**: on Windows you can rename `Metin2Launcher.exe` while it's running, then write the new file at the original path. The running process keeps its file handle open via the renamed copy. Next start picks up the new launcher. This works without a trampoline and is what we use for the launcher's own self-update. +- **Trampoline** (only if rename-before-replace fails): a ~50 KB `launcher-update.exe` that waits for our PID to exit, replaces the main launcher, then exits. Kept as fallback. + +### 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 --version 2026.04.14-1 --source /path/to/fresh/client + ``` +2. The script walks the client directory, computes sha256 for each file, writes a `manifest.json`, signs it, and produces a release directory `release/2026.04.14-1/` containing the manifest, its signature, and only the new blobs (ones not already present on the server). +3. Human review: diff the new manifest against the previous one, sanity-check size and file count. +4. `rsync` the release directory to the VPS: + ```bash + rsync -av release/2026.04.14-1/ mt2.jakubkadlec.dev@mt2.jakubkadlec.dev:/var/www/updates.jakubkadlec.dev/ + ``` +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 | + +## 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 + verify | 2 d | `launcher/` project | +| 7 | Launcher — file diff + download + hash verify + atomic apply | 2 d | | +| 8 | Launcher — self-update with rename-before-replace + `.old` rollback | 1 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 1–10**, 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. -- 2.49.1 From 6f70ef201aadd8de188003521aef59c6a06401d9 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 10:36:24 +0200 Subject: [PATCH 2/7] 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/. --- docs/examples/manifest-example.json | 52 ++++++++++++ docs/update-manifest.md | 124 ++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 docs/examples/manifest-example.json create mode 100644 docs/update-manifest.md diff --git a/docs/examples/manifest-example.json b/docs/examples/manifest-example.json new file mode 100644 index 00000000..02e1c964 --- /dev/null +++ b/docs/examples/manifest-example.json @@ -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 + } + ] +} diff --git a/docs/update-manifest.md b/docs/update-manifest.md new file mode 100644 index 00000000..67e4fecc --- /dev/null +++ b/docs/update-manifest.md @@ -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. -- 2.49.1 From 605f8765d5e062d5071f8592a1b53b93b70b1853 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 10:36:24 +0200 Subject: [PATCH 3/7] 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. --- scripts/make-manifest.py | 220 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100755 scripts/make-manifest.py diff --git a/scripts/make-manifest.py b/scripts/make-manifest.py new file mode 100755 index 00000000..f7ef19df --- /dev/null +++ b/scripts/make-manifest.py @@ -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()) -- 2.49.1 From 759b31d3901ab8cabf54913cb13cc5ae612db2ab Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 10:44:44 +0200 Subject: [PATCH 4/7] 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. --- docs/update-manager.md | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/update-manager.md b/docs/update-manager.md index b5684f36..b901a071 100644 --- a/docs/update-manager.md +++ b/docs/update-manager.md @@ -119,10 +119,19 @@ Launcher does, in order: ### Self-update details -The Windows filesystem does not allow replacing a currently-running executable. Two common patterns: +We do not implement self-update from scratch. The launcher embeds **[Velopack](https://github.com/velopack/velopack)** (MIT, Rust+.NET, actively maintained), which handles: -- **Rename-before-replace**: on Windows you can rename `Metin2Launcher.exe` while it's running, then write the new file at the original path. The running process keeps its file handle open via the renamed copy. Next start picks up the new launcher. This works without a trampoline and is what we use for the launcher's own self-update. -- **Trampoline** (only if rename-before-replace fails): a ~50 KB `launcher-update.exe` that waits for our PID to exit, replaces the main launcher, then exits. Kept as fallback. +- 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 @@ -191,6 +200,27 @@ Trade-off: automation speed vs. the attack surface of a CI-held signing key. Whe | 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. @@ -202,9 +232,9 @@ Effort is real-days of Claude + review time from the team. | 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 + verify | 2 d | `launcher/` project | +| 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 — self-update with rename-before-replace + `.old` rollback | 1 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 | | -- 2.49.1 From b7e4514677ae3bfe484de66c9d80b15df0628f42 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 11:22:32 +0200 Subject: [PATCH 5/7] scripts: add manifest signer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/sign-manifest.py | 102 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100755 scripts/sign-manifest.py diff --git a/scripts/sign-manifest.py b/scripts/sign-manifest.py new file mode 100755 index 00000000..63948a6d --- /dev/null +++ b/scripts/sign-manifest.py @@ -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: .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()) -- 2.49.1 From 02061f6e070304609b73f11de230898ce009e7e7 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 11:22:32 +0200 Subject: [PATCH 6/7] 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. --- docs/caddy-updates.conf | 71 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/caddy-updates.conf diff --git a/docs/caddy-updates.conf b/docs/caddy-updates.conf new file mode 100644 index 00000000..ffa9178c --- /dev/null +++ b/docs/caddy-updates.conf @@ -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/ +# │ └── / 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 + } +} -- 2.49.1 From fdb9e980751b00f29c937967044d0fafcce18fb5 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 11:35:39 +0200 Subject: [PATCH 7/7] 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. --- docs/runbook-caddy-updates.md | 210 ++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/runbook-caddy-updates.md diff --git a/docs/runbook-caddy-updates.md b/docs/runbook-caddy-updates.md new file mode 100644 index 00000000..31c44a1e --- /dev/null +++ b/docs/runbook-caddy-updates.md @@ -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. /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. /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. /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//`. +- 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. /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. -- 2.49.1