diff --git a/README.md b/README.md index b937991..f925125 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ Peer-to-peer game library sharing for LAN parties. Peers discover each other on the local network via mDNS, exchange library metadata over QUIC, and let users browse and download games from each other. Ships as a Tauri desktop app. +## Peer identity and protocol status + +Each desktop installation creates and reuses one Ed25519 identity in its Tauri +app-data directory. Its `PeerId` is lowercase unpadded RFC 4648 base32 of BLAKE3 +over the certificate's complete canonical DER SubjectPublicKeyInfo; certificates +and private keys are never distributed from the repository. Outbound QUIC +connections authenticate the responder against the exact expected `PeerId`. Wire +protocol 8 (`lanspread/8`) is the only supported protocol. Peers exchange +responder-owned, revisioned snapshots over identity-pinned endpoints; file and +streamed-install requests name the catalog's exact `ContentId`. There are no +legacy wire fallbacks or repository-shared identity keys. + ## Build / install Install Rust, Deno, and `just` first, then bootstrap the project: @@ -33,12 +45,20 @@ Create production bundles: just bundle ``` +Development commands use the checked-in peer-CLI fixture catalog only after its +database and manifests pass the catalog checker. Production bundles never use +that fixture authority: `just bundle` validates the production `game.db` and +requires a generated manifest for every catalog game before Tauri packaging can +start. This checkout does not include the canonical production package corpus, +so maintainers must generate and independently check those manifests beside the +production packages before a bundle can succeed. + ## Important just commands - `just setup` - install the Tauri CLI and frontend dependencies. - `just run` - run the Tauri app in dev mode. - `just build` - build the app without bundling. -- `just bundle` - create production bundles. +- `just bundle` - validate the complete production catalog and create bundles. - `just fmt` - format Rust, TOML, and the justfile. - `just clippy` - lint the Rust workspace. - `just test` - run workspace tests. diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index f7fabe1..a32e61a 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -1,8 +1,9 @@ -# lanspread-peer proposed protocol and architecture +# lanspread-peer architecture -This document proposes a tighter, more fault-tolerant protocol while keeping the -current idea: mDNS discovery, QUIC transport, on-demand metadata, and chunked -file transfers. +The peer uses mDNS discovery, authenticated QUIC transport, responder-owned +metadata snapshots, and catalog-authorized file transfers. Wire protocol 8 and +ALPN `lanspread/8` are the only supported wire mode; there is no legacy decode, +fallback, or compatibility shim. ## Goals (unchanged) @@ -11,136 +12,181 @@ file transfers. - UI drives operations through `PeerCommand`, peers remain headless. - Peers can appear/disappear at any time without data loss. +## Installation identity and QUIC authentication + +- One installation-local Ed25519 key signs one self-issued TLS certificate. + `PeerId` is BLAKE3 over the certificate's complete canonical DER + SubjectPublicKeyInfo, encoded as 52 lowercase unpadded RFC 4648 base32 + characters. It is not a UUID, display name, IP address, or wire assertion. +- The desktop app stores a strict version-1 record at `peer-identity-v1.json` in + its Tauri app-data directory. The record binds the Ed25519 certificate, + private key, SPKI-derived `PeerId`, and exact SAN. Reads and publication do + not follow symlinks or reparse points, creation is no-clobber, corrupt bytes + are quarantined before replacement, and private material is kept at mode 0600 + where the platform supports Unix modes. +- Persistence failure produces a typed `Ephemeral` outcome and a fresh identity + for that runtime; it never falls back to another peer-ID scheme. The peer CLI + can instead select an existing record with `--identity-file`; that path is + loaded strictly and is never repaired, replaced, or generated implicitly. +- Identity state assumes one normal application owner for its app-data directory + and deliberately has no cross-process identity lease. Retained no-follow + handles contain path traversal and external-target mutation, and publication + does not overwrite an existing winner. A concurrent same-user writer can still + cause availability or continuity loss during quarantine; it is outside the + supported ownership model. +- QUIC uses the rustls provider with TLS 1.3 only. Every outbound operation + carries a `PeerEndpoint { peer_id, addr }`; the expected ID is encoded in an + exact SNI name, checked against the responder certificate SPKI, and the real + TLS 1.3 CertificateVerify signature is delegated to rustls. ALPN is + `lanspread/8`. Client resumption and 0-RTT are disabled; the server issues no + tickets and sends no early or half-RTT data. + ## Peer lifecycle and message flow ### 1) Startup and advertise - Start QUIC server. - Advertise via mDNS with TXT records: - - `peer_id` (stable ID, not tied to IP) + - `peer_id` (the canonical SPKI-derived ID) - `proto_ver` - - `library_rev` (monotonic local library revision) - optional `hostname` ### 2) Discovery and handshake When a peer is discovered: -1. Connect and send - `Hello { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }`. - `listen_addr` is mandatory; the QUIC source port is only a temporary - transport port and must not be recorded as the peer's listener. -2. Receive - `HelloAck { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }`. -3. If the remote `peer_id` is already known but the address changed, update it. -4. If protocol versions are incompatible, drop the peer (and keep mDNS - watching). -5. If library digests match, do nothing else. -6. If digests differ: - - If we have a known `library_rev` for that peer, request `LibraryDelta`. - - Otherwise request `LibrarySnapshot`. +1. Parse `peer_id`, address, and `proto_ver` into a candidate `PeerEndpoint`. + Discovery is not authentication and does not add the peer to `PeerGameDB` or + emit UI membership events. The mDNS ingress queue and active candidate + negotiations are each capped at 64. +2. Reserve a candidate negotiation lease before queueing or awaiting work, then + establish a TLS-pinned connection to that exact endpoint. Missing or non-v8 + records are rejected, and neither an ephemeral QUIC source port nor a payload + can replace the candidate listener address. +3. Send the empty `Hello` pull request. The pinned responder returns a + `HelloSnapshot` containing a `PeerStateSnapshot` with `runtime_session_id`, + `library`, and `call_to_play`: only that responder's local library and local + Call-to-Play author slice. +4. Decode the strict bounded wire shape, then validate the library and + Call-to-Play domains independently. Commit the endpoint and each valid domain + only if the candidate lease is still current for both peer ID and address. + This domain isolation lets an invalid remote Call-to-Play slice be cleared or + preserved according to its session without discarding a valid library. +5. Assign a fresh endpoint generation to every successful authentication. + Dropped, failed, or superseded work releases its candidate claims, and a late + result cannot mutate a newer generation. ### 3) Steady state -- Any message updates `last_seen`. -- Pings run only when idle (or on a longer interval), not every 5 seconds. -- Library updates are pushed as deltas, debounced and coalesced. -- Call to Play actions are broadcast as immutable, uniquely identified events. +- Successful liveness probes update `last_seen` only when both the typed + endpoint and its authenticated generation are still current. Stale probes + cannot refresh or remove a reauthenticated endpoint. +- `Ping` receives a `Pong` carrying `PeerRevisions` with the runtime-session ID + and both domain revisions. A session or revision mismatch schedules a full + pinned pull, so idle liveness heals dropped change hints. +- Local library or Call-to-Play changes send cheap revision hints. Hints are + untrusted and lossy: unknown claims are ignored, and a known claim can only + coalesce a pull from the claimed peer's already authenticated endpoint. +- The state-sync scheduler caps its ingress and tracked peers at 64, coalesces + work for five seconds, and runs at most eight pinned pulls and eight hint + sends concurrently. Child work is drained lexically on shutdown. +- The server admits at most 64 unauthenticated handshakes, 64 established + connection scopes, and 32 control streams per connection. Each control stream + accepts one frame followed by request EOF and sends at most one response; the + frame cap is 8 MiB and control I/O has ten-second deadlines. ### Call to Play replication -Call to Play is transient peer-session state rather than database state. The -peer keeps a bounded event history, deduplicated by event ID. Every event and -chat message remains in the snapshot for the full lifetime of an active call, so -a peer joining mid-call receives the complete context. A creator's `Start` or -`Cancel` makes the call terminal and read-only, but its complete roster and chat -history remain in snapshots for 15 minutes so late joiners can see the outcome. -After that display window the history compacts to the Start or Cancel tombstone -for the rest of the peer session. Active and recently terminal calls are never -partially trimmed. If genuinely active history reaches the bound, local -publishes return an error to the caller instead of appearing to succeed; -terminal histories and tombstones do not consume that active-history capacity. A -call whose deadline elapses remains available for five minutes so the creator -can start or extend it, then its unresolved history is evicted as a unit. A -local action is applied to that history, sent to the UI, and broadcast to every -currently known peer. An incoming live event is applied once and sent to the UI -without being rebroadcast, which prevents forwarding loops. +Call to Play is transient, responder-owned state. Each peer serves exactly one +local author snapshot: -Live Call to Play delivery is acknowledged by the receiver. Applied, duplicate, -and obsolete events need no follow-up. An unknown envelope peer, missing call -root, transport failure, or malformed acknowledgement makes the sender perform -one normal `Hello` / `HelloAck` exchange with that peer. The handshake carries -the full retained history in both directions, so a transient request failure -heals without waiting for mDNS rediscovery or a later reconnect. A rejected -event is logged without retry. Local publication remains successful while this -healing happens asynchronously, so an offline peer cannot block an action. +```text +CallToPlayAuthorSnapshot { revision, display_name, events } +CallToPlayAuthorEvent { id, call_id, at, action } +``` -Actors are keyed by the peer's stable ID and carry a separate display name. The -origin peer overwrites the actor ID on local actions. A live-event envelope must -name a peer already in the receiver's roster, and every enclosed actor ID must -match that envelope. This prevents accidental identity mixing and protects -creator controls from other normal clients. It is not authentication against a -hostile LAN peer: all peers use the shared application TLS identity, and stable -peer IDs are self-asserted under the project's trusted-LAN model. +The outer `PeerStateSnapshot` owns the runtime-session ID. A `CallId` encodes +the creator's typed `PeerId` plus a 128-bit nonce. The UI supplies only a local +intent and current display name; the peer core generates call/event nonces and +timestamps. Identity and display name are not repeated inside events. Creator +actions (`Create`, `Start`, `Cancel`, and `AddTime`) must come from the +`CallId.creator`; participant authors may contribute only participant actions. -`Hello` and `HelloAck` include each side's event history. This lets peers that -join after a call was created reconstruct the same nominations, responses, -RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream -deterministically and derives deadlines and check-in phases from timestamps. -Those phases compare creator-supplied wall-clock timestamps with each viewer's -local wall clock, so LAN machines are assumed to be synchronized closely enough -for human-scale minute countdowns; clock skew shifts the displayed boundary by -the same amount. There is deliberately no compatibility path for older protocol -versions. +The receiver assigns author identity from the pinned `PeerEndpoint`, validates +and normalizes the whole author slice off-lock, then commits it against that +endpoint's current generation and runtime session. For the same session, only a +higher revision replaces the slice; an equal identical snapshot is a no-op, an +equal different snapshot is a conflict, and a lower revision is stale. A new +authenticated session clears the previous slice before accepting valid new +state. Invalid same-session state preserves the last valid slice, while invalid +new-session state leaves that author absent. + +Participant events are retained but hidden until the creator's direct author +slice supplies the call root. Removing a participant removes only that author's +rows; removing the creator hides the entire call. Because every snapshot is +served only by its author and attributed from the pinned responder, there is no +union merge, relay, live-event push, acknowledgement, or author claim on the +wire. The UI receives a deterministic complete replacement view. + +Unresolved call history is removed as a whole after the deadline plus five +minutes; terminal history is removed as a whole after `Start` or `Cancel` plus +15 minutes. No tombstone survives pruning. Pruning a local slice advances its +revision before a snapshot or `Pong` is served, allowing peers to discover the +removal. The store caps total authors at 64, events per author at 4,096, and one +encoded author snapshot at 4 MiB, with reserved capacity for terminal actions. ### 4) Shutdown -- Optional `Goodbye { peer_id }` lets others remove the peer quickly. -- If a peer vanishes without goodbye, stale timeout + ping removal handle it. -- Goodbye is a hint, never required for correctness. +- Cancellation stops admission, drains all lexically owned connection, stream, + state-sync, operation, and mDNS children, closes the shared endpoints, and + joins the supervisor. +- There is no `Goodbye` control message. Pinned liveness failure and stale + generation-conditional removal are authoritative for departure. ## Library sync protocol -### Summary and snapshot +The responder's full library slice is +`LibrarySnapshot { revision, games: Vec }`, where each sorted, +unique availability row contains only `game_id` and the catalog-derived exact +`ContentId`. A snapshot contains at most 4,096 games. It does not carry titles, +versions, paths, sizes, file lists, hashes chosen by the peer, installed state, +or another peer's availability. -- `LibrarySummary { peer_id, summary: { library_rev, library_digest, game_count } }` -- `LibrarySnapshot { peer_id, snapshot: { library_rev, games: Vec } }` +The empty `Hello` request always returns the responder's current complete slice. +For the same runtime session, a higher revision atomically replaces the cached +slice; lower or equal stale work cannot roll it back. A new authenticated +runtime session resets the old domain before its candidate snapshot is +considered. `LibraryChanged(ChangeHint)` carries only an untrusted claimed +identity, runtime session, and revision. It never mutates a library directly and +has no delta payload. A pinned `Pong` revision mismatch schedules the same full +pull. -### Delta updates +## Local library publication -- `LibraryDelta { peer_id, delta: { from_rev, to_rev, added, updated, removed } }` -- `removed` is a list of game IDs. -- Deltas are idempotent; ignore if `to_rev` <= known rev. - -### GameSummary (concept) - -- `id`, `name`, `eti_version`, `size`, `downloaded`, `installed` -- `manifest_hash` (hash of file list + sizes) -- `availability` (e.g., `ready`, `downloading`, `local_only`) - -## When peers broadcast their game list - -- Only on changes, not on a timer. +- Only real local availability changes advance the revision and schedule a hint; + there is no periodic list broadcast. - Filesystem events are gated per game ID instead of time-debounced: - an active operation lock drops events for that game; - a rescan already running for the ID sets a rescan-pending flag; - the running rescan loops once more when that flag was set. - Local library scans emit `LocalLibraryChanged` only for real library changes, except that accepted game-directory changes can force a UI snapshot for the - new path without sending a peer delta. + new path without changing peer availability. - Active operation mutations emit `ActiveOperationsChanged` from the mutation path instead of riding on local library scans. -- Send `LibraryDelta` to known peers; send `LibrarySummary` on new connections. +- The remote UI projection is a wholesale replacement derived from all current + authenticated per-peer slices; two peers offering the same game remain + distinct exact-content sources. ## Local game scanning: fast and low cost ### Strategy -1. Maintain a persistent on-disk index (per game): - - `manifest_hash`, total size, file list (optional), and a fingerprint - (root-level `version.ini` mtime, root-level `.eti` mtime/size, and `local/` - directory presence). -2. Use filesystem watchers to update only changed games. +1. Maintain a persistent revisioned on-disk index of local summaries and cheap + per-game fingerprints (root-level `version.ini` contents/mtime, root-level + `.eti` name/size/mtime, `local/` presence, and recovery state). +2. Poll a bounded non-recursive metadata snapshot once per second and update + only changed games. 3. Keep a 300-second fallback scan to recover from missed events. ### Fast-path scanning @@ -150,7 +196,7 @@ versions. - root-level `.eti` file names, sizes, and mtimes - root-level `version.ini` mtime - presence of `local/` as a directory -- If fingerprint unchanged, reuse cached size and manifest hash. +- If the fingerprint is unchanged, reuse the cached local summary. - Only run a recursive scan for new or changed games. ## Local State and Recovery @@ -179,11 +225,14 @@ Reserved per-game paths: flight. - `games//install_intent.json` in the configured state directory is the atomic per-game intent log. -- `games//download_ownership.json` in that state directory records the - last committed and any pending downloader-owned regular-file set. The record - is bound to one canonical configured games directory. +- `games//download_ownership/v1-/record.json` in that + state directory records the last committed and any pending downloader-owned + regular-file set and its exact catalog content ID. The namespace is derived + from one canonical configured games directory, and the record retains the full + lossless root identity. Same-ID state for different configured roots therefore + remains independent. - `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership - when the current intent is `None`. + when no active intent independently proves transaction ownership. Downloaded-file removal is not an uninstall transaction. For a catalog ID that is a single direct child of the configured game directory, it requires a valid @@ -192,12 +241,18 @@ ownership record and regular root-level `version.ini`, and refuses `local/`, generation, deletes only committed downloader-owned files and the sentinel, and keeps the game root plus every unknown file or directory. -Recovery reads app-state `install_intent.json` and combines the recorded intent -with the observed `local/`, `.local.installing/`, and `.local.backup/` state. -Intent states `Installing`, `Updating`, and `Uninstalling` prove ownership of -the corresponding reserved directories even if the marker was not flushed before -a crash. With intent `None`, markerless `.local.*` directories are left -untouched. +The state layout is game-first: root namespaces live under each game ID rather +than under a global root tree. A former singleton ownership record is migrated +once into its derived namespace with copy-first durable publication. Singleton +state is not accepted as a parallel runtime format, and conflicting or split +migration evidence fails closed. + +Recovery reads app-state `install_intent.json` and combines the recorded active +intent with the observed `local/`, `.local.installing/`, and `.local.backup/` +state. Intent states `Installing`, `Updating`, and `Uninstalling` prove +ownership of the corresponding reserved directories even if the marker was not +flushed before a crash. Settled state is represented by absence of the intent; +markerless `.local.*` directories are then left untouched. Legacy `.lanspread/`, `.lanspread.json`, `.lanspread.json.tmp`, `.softlan_game_installed`, and `local/.softlan_first_start_done` files are @@ -210,21 +265,49 @@ Most scans become O(number of game dirs), with full recursion only when needed. ## File manifests and downloads -- Keep `GetGame`/manifest requests, but keyed by `manifest_hash` so repeated - calls can be skipped when unchanged. -- The complete remote description is converted into a - `ValidatedDownloadManifest` before any destination mutation. It contains - canonical game-root-relative paths and rejects aliases, reserved state, shape - conflicts, and bounded-size violations as one unit. +- Tauri and the peer CLI inject one immutable `CatalogBundle`: `game.db` defines + catalog identity/version, the mandatory compact + `manifests/catalog-content-index-v1.jsonl` maps every exact catalog row to its + expected `ContentId` and Stream Install capability, and + `manifests/.json` defines canonical paths, kinds, sizes, 128 MiB + chunks, and BLAKE3 values. The index has exact database coverage and is loaded + once; manifest bodies remain on demand and must recompute both indexed fields. + Disk body loads and full validation recheck the durable publication marker + before and after their work. Runtime peers cannot add catalog entries or + replace that byte authority. +- Authenticated remote-availability joins use the compact index only. Unknown or + mismatched `(game_id, content_id)` pairs are rejected without filesystem I/O + or manifest parsing; actual serving, download planning, and Stream Install + still load and validate the full body. +- The local catalog artifact is converted into a `ValidatedDownloadManifest` + before any destination mutation. It contains canonical game-root-relative + paths and rejects aliases, reserved state, shape conflicts, and bounded-size + violations as one unit. Remote peers never define the storage plan. +- Source selection requires the exact catalog `(game_id, content_id)` pair from + an authenticated peer's current library slice. Every ordinary chunk request + repeats the exact `ContentId`, `CanonicalCatalogPath`, offset, and length. + There is no version-only or address-only eligibility adapter. - A new manifest target that already exists without prior committed ownership is rejected before the sentinel, ownership journal, or payload is mutated. - Download mutation holds a capability handle for the direct catalog game root. Directory components and final files are reopened relative to that handle without following links or Windows reparse points; chunk writes and checks use the same opened file handle. -- Downloads remain chunked QUIC streams with the existing integrity checks. +- Downloads hash every chunk, including the buffered `version.ini`, as it is + received and require the catalog BLAKE3 value, exact length, and exact end of + stream. One absolute ten-minute application deadline covers the complete + open/request/receive/check lifecycle of each ordinary chunk. +- An integrity failure quarantines `(PeerId, content_id)` in shared memory for + the rest of that peer runtime, independent of address changes. Transport + failures remain retryable without quarantine. Each failed chunk may try every + distinct eligible peer identity once; there is no separate numeric retry cap. +- A no-transfer local shortcut is allowed only when settled ownership records + the exact expected committed content ID. A matching `version.ini` alone, or a + legacy/pre-content-ID record, is not catalog-content proof. - A game is transferable only when its ID is in the catalog, no operation is active for that ID, and the root-level `version.ini` sentinel exists. +- The sender admits only an exact catalog path and, for ranged requests, an + exact catalog chunk range before opening the retained no-follow file handle. - `local/` paths are never served, even if a stale or malicious manifest request asks for them. - Cancelling or recovering a download removes only paths named by its durable @@ -236,65 +319,69 @@ Most scans become O(number of game dirs), with full recursion only when needed. - Low-disk streamed installs request archive-derived file bytes from one peer and write them directly into the install transaction staging directory. -- The receiver verifies every streamed file against the sender archive's file - size and RAR CRC32 before the transaction may commit. This catches truncated - streams, transport corruption, and provider bugs. -- This is not malicious-peer protection: the peer controls both the archive - metadata and the streamed bytes. A trusted-content model needs catalog-owned - hashes, either for the root archives or for extracted files, and receiver-side - SHA-256 verification against those catalog values before commit. +- A sender admits Stream Install only for a manifest with verified extracted + output and only when its direct regular root `.eti` set exactly equals the + catalog archive set. Missing or extra archives stop before the extraction + provider receives authority. +- The Stream Install request names the exact catalog `ContentId`. All + path-bearing frames, including archive names, use `CanonicalCatalogPath` and + the fallible checked decoder distinguishes malformed framing from an explicit + sender `Error` frame. +- The receiver independently requires the exact catalog archive set and the + complete extracted path/kind/size set. It hashes every regular file with + BLAKE3 while writing and verifies every expected entry before commit. Regular + files may occur only once across the entire archive set; repeated directory + entries are allowed because they carry no bytes. RAR CRC32 remains only an + early corruption signal, not an authority boundary. +- Integrity failures quarantine the source for that catalog content and retry a + fresh transaction from another eligible peer. Transport failures retry without + quarantine; local I/O and cancellation stop. Every failed attempt rolls back + before the next source begins. +- After complete catalog verification, account/language/persona settings are + rewritten in staging before promotion. The one-shot launch-settings marker is + written only after successful promotion; if it cannot be written, first play + safely retries the rewrite. +- A game without a verified extracted-file manifest neither offers nor accepts + Stream Install. There is no CRC32-only fallback. + +## Catalog publication and packaging + +- `lanspread-catalog-publisher` generates production manifests and their + complete compact identity index beside the canonical packages, independently + rebuilds/verifies each selected artifact, and uses a durable corpus marker so + an interrupted body/index publication fails closed. Incremental generation + requires an existing exact indexed corpus and atomically republishes the full + index after the selected bodies. After it owns the marker, it freshly + validates every unselected body/index pair before deriving that mixed index; + no pre-marker snapshot can roll another completed publication backward. +- `lanspread-fixture-catalog` is a separate test-only generator. It derives + reduced peer-CLI `game.db` files and manifests from explicitly selected + fixtures; those outputs are development and acceptance-test authority only. +- Tauri fixture builds require both the exact committed development resource map + and `LANSPREAD_USE_FIXTURE_CATALOG=1`. Every other build mode defaults to the + production resource map, and the custom production profile cannot be + downgraded to fixtures. +- Production packaging requires the complete production `game.db`/manifest + corpus to pass `check --all`. The canonical production packages and their 186 + generated manifests are not present in this checkout, so that gate remains + intentionally blocked; fixture success is not production completion. ## Fault tolerance rules -- Every peer is keyed by `peer_id`, not by IP address. -- Peer addresses are listener addresses from mDNS or `Hello`/`HelloAck`, never - ephemeral QUIC source ports. -- `library_rev` is monotonic and guards against out-of-order updates. -- Any mismatch or missing delta falls back to `LibrarySnapshot`. -- Loss of goodbye is harmless; stale timeout is authoritative. - -## Roadmap from current design to this one - -1. Protocol updates in `lanspread-proto`: - - Define `Hello`, `HelloAck`, `LibrarySummary`, `LibrarySnapshot`, - `LibraryDelta`, and optional `Goodbye` messages. - - Thread `peer_id`, `library_rev`, and `manifest_hash` through all library - and manifest-bearing types. - - Make `Hello` and `HelloAck` carry the sender's `listen_addr`, - `library_rev`, and `library_digest` so both sides can record stable - listener addresses and immediately select `LibraryDelta` vs - `LibrarySnapshot`. -2. Peer identity: - - Persist a stable `peer_id` (UUID) in the peer config and inject it into - `PeerInfo` and `PeerGameDB` at startup. - - Track `peer_id -> SocketAddr` in the discovery table and update the address - on any incoming handshake or mDNS refresh. -3. Discovery handshake: - - Publish `peer_id` and `library_rev` in mDNS TXT records to avoid immediate - TCP/QUIC roundtrips when nothing changed. - - Add a lightweight handshake in `run_peer_discovery` that exchanges - `Hello`/`HelloAck` before any library sync. - - Ignore peers that do not advertise the current protocol version. -4. Library revisioning: - - Store a monotonic `library_rev` locally and increment only after a - successful index refresh completes. - - Apply `LibraryDelta` when `library_rev` matches; reject stale or future - revisions and request `LibrarySnapshot` instead. - - Cache the last accepted `manifest_hash` per peer to short-circuit manifest - requests when unchanged. -5. Local index + scan optimizations: - - Use the cached `local_library/index.json` file in the configured state - directory to store per-root fingerprints and computed manifests. - - Use filesystem watchers with a debounce window to collect changes and - incrementally update the cache. - - Schedule a low-frequency full scan to reconcile missed watcher events. -6. Announce updates: - - Broadcast `LibraryDelta` updates keyed by `library_rev`. - - Send `LibrarySummary` on new connections to seed the delta flow. -7. File manifest caching: - - Store per-game `manifest_hash` and only fetch details when changed. -8. Liveness: - - Reduce ping frequency; update `last_seen` on any message. - - Add optional `Goodbye` on shutdown paths. -9. Tests: - - Delta apply/merge, rev ordering, manifest hashing, and scan cache behavior. +- Every authenticated peer is keyed by `PeerId`, not by IP address. +- An authenticated `PeerEndpoint` retains the candidate or explicitly supplied + listener address. Payload fields and ephemeral QUIC source ports cannot + rewrite it, and no network caller can dial an address without the expected + responder ID. +- Every successful authentication assigns a fresh endpoint generation. Refresh, + liveness update, and stale removal are conditional on the exact endpoint and + generation they observed. +- Runtime-session IDs and per-domain revisions prevent delayed snapshots from + rolling state back across restart or endpoint replacement. Change hints never + bypass the pinned pull and generation checks. +- A generation-conditional authenticated departure removes that peer's library + and Call-to-Play slices and publishes complete replacement views. Ordinary + short-lived QUIC connection closure is not roster departure. +- Protocol 8 is strict and current-only. Unknown fields, noncanonical typed IDs + or paths, duplicate/unsorted library rows, oversized domains, extra control + frames, and v7 messages are rejected instead of adapted. diff --git a/crates/lanspread-peer/README.md b/crates/lanspread-peer/README.md index bbf7a17..17f7810 100644 --- a/crates/lanspread-peer/README.md +++ b/crates/lanspread-peer/README.md @@ -10,37 +10,45 @@ It is designed to run headless – other crates (most notably - `start_peer(game_dir, tx_events, peer_game_db, unpacker, catalog)` boots the asynchronous runtime in the background and returns a `PeerRuntimeHandle` whose sender controls the peer. The injected `Unpacker` keeps archive extraction out - of the peer crate's platform layer, and the catalog set gates which local game - roots are announced or served. -- `PeerCommand` represents the small control surface exposed to the UI layer: - `ListGames`, `GetGame`, `FetchLatestFromPeers`, `DownloadGameFiles`, - `StreamInstallGame`, `InstallGame`, `UninstallGame`, `RemoveDownloadedGame`, - `CancelDownload`, `SetGameDir`, and `GetPeerCount`. + of the peer crate's platform layer, and the immutable `CatalogBundle` gates + which local game roots are announced or served. +- `PeerCommand` is the UI-facing control surface for complete library views, + exact-content downloads and installs, game-directory changes, direct typed + endpoint connections, and locally authored Call-to-Play intents. - `PeerEvent` enumerates everything the peer runtime reports back to the UI: - library snapshots, download/install/uninstall lifecycle updates, runtime - failures, and peer membership changes. -- `PeerGameDB` collects remote peer metadata. It aggregates discovered peers’ - `Game` definitions, tracks the latest ETI version per title, and keeps the - last seen list of `GameFileDescription` entries for each peer. + wholesale remote-library and Call-to-Play views, download/install/uninstall + lifecycle updates, runtime failures, and authenticated peer membership + changes. +- `PeerGameDB` collects metadata only for TLS-authenticated peer endpoints. It + records the endpoint generation, runtime session, per-domain revisions, and + exact `GameAvailability { game_id, content_id }` rows supplied by that pinned + responder. It does not accept remote file manifests or descriptions. -Internally the peer runtime owns four long-lived tasks that run for the lifetime -of the process: +Internally the peer runtime owns five coordinated services that run for the +lifetime of the process: 1. **Server component** (`run_server_component`) – listens for QUIC connections, - advertises via mDNS, and serves `Request::ListGames`, `Request::GetGame`, - `Request::GetGameFileData`, `Request::GetGameFileChunk`, and - `Request::StreamInstall` by reading from the local game directory. + advertises via mDNS, and serves bounded `Ping`, `Hello`, change-hint, + exact-content chunk, and Stream Install requests. 2. **Discovery loop** (`run_peer_discovery`) – uses the `lanspread-mdns` helper - to discover other peers. The blocking mDNS work is executed on a dedicated - thread via `tokio::task::spawn_blocking` so that the Tokio runtime remains - responsive. + to observe candidate endpoints. A dedicated joinable OS thread owns the + blocking browser. Its nonblocking ingress queue and the async set of active + candidate negotiations are each bounded at 64; repeated observations that + overflow ingress are coalesced by dropping the hint. 3. **Ping service** (`run_ping_service`) – periodically issues QUIC ping requests to keep peer liveness up to date and prunes stale entries from - `PeerGameDB`. -4. **Local game monitor** (`run_local_game_monitor`) – watches the configured - game directory and each game root non-recursively, gates per-ID rescans while - operations are active, emits local-library changes separately from active - operation snapshots, and runs a 300-second fallback scan for missed events. + `PeerGameDB`. Every probe captures the authenticated endpoint generation; + success, failure, and stale removal are conditional on that exact endpoint + and generation, so delayed work cannot affect a reauthenticated peer. +4. **Local game monitor** (`run_local_game_monitor`) – polls a bounded + non-recursive metadata snapshot of the configured game directory and its + direct game roots once per second, gates per-ID rescans while operations are + active, emits local-library changes separately from active-operation + snapshots, and runs a 300-second fallback scan for broader reconciliation. +5. **State-sync scheduler** (`run_state_sync`) – coalesces lossy revision hints, + compares authenticated `Pong` revisions, and performs bounded `Hello` pulls + from exact pinned responders. Its queue and peer table are each capped at 64 + entries, with at most eight pulls and eight hint sends running concurrently. `scan_local_library` maintains a lightweight on-disk index and produces both a `GameDB` and protocol summaries. A game is downloaded only when its root-level @@ -48,65 +56,134 @@ of the process: ## Networking and File Transfer -- Transport is handled by [`s2n-quic`](https://github.com/aws/s2n-quic); TLS - cert/key material is compiled in from the repository root. -- Protocol messages are JSON-encoded structures defined in - `lanspread-proto::{Request, Response}`. +- Transport is handled by [`s2n-quic`](https://github.com/aws/s2n-quic) with its + rustls provider. Every outbound operation requires a typed + `PeerEndpoint { peer_id, addr }` and TLS 1.3 pins the responder's complete DER + SPKI to that expected ID before delegating the real CertificateVerify + signature to rustls. ALPN is `lanspread/8`; resumption, tickets, 0-RTT, and + half-RTT data are disabled. +- Protocol messages are strict, fallible JSON structures defined in + `lanspread-proto::{Request, Response}`. Protocol 8 is the only accepted wire + shape. A control stream carries exactly one request frame followed by EOF and + at most one response; frames are capped at 8 MiB and control I/O is bounded by + ten-second deadlines. - File transfers stream raw bytes over dedicated bidirectional QUIC streams. - `peer::send_game_file_data` sends entire files, while - `peer::send_game_file_chunk` services ranged requests. + `peer::send_game_file_chunk` services catalog-authorized ranges. The bounded + server admits at most 64 unauthenticated handshakes, 64 established connection + scopes, and 32 control-stream tasks per connection. + +Each installation owns one Ed25519 key and one self-issued certificate. `PeerId` +is lowercase unpadded RFC 4648 base32 of BLAKE3 over the certificate's complete +canonical DER SPKI. The desktop app loads or creates the strict version-1 +`peer-identity-v1.json` record in Tauri's app-data directory. Record loading +checks the exact version, algorithm, canonical base64, size bounds, +certificate/key match, derived SPKI ID, and exact SAN. Opens and publication do +not follow symlinks or reparse points, creation does not clobber an existing +winner, corrupt bytes are preserved in quarantine before replacement, and Unix +private material is mode 0600. + +Read, permission, quarantine, or write failure returns a typed `Ephemeral` +persistence outcome and uses a fresh identity for that runtime. The peer CLI's +`--identity-file` selects an existing record strictly and never repairs, +quarantines, replaces, or generates that explicit path. Identity persistence +supports one normal app-data owner and has no cross-process lease; a concurrent +same-user writer can cause availability or continuity loss, but retained +no-follow handles contain traversal and publication never overwrites a +concurrent winner. + +### Responder-owned state and Call to Play + +`Hello` is an empty pull request. A successful pinned responder returns only its +own `PeerStateSnapshot`: one runtime-session ID, a revisioned local library +slice, and a revisioned local Call-to-Play author slice. The receiver validates +the library and Call-to-Play domains independently and commits them only while +the authenticated endpoint generation is still current. A peer never relays +another peer's library or Call-to-Play state. + +Local changes send cheap `LibraryChanged` or `CallToPlayChanged` revision hints. +Hints are untrusted and lossy: an unknown claimed peer is ignored, and a known +hint can only coalesce a full pull from that peer's pinned endpoint. `Pong` +carries the responder's runtime session and both revisions, so idle liveness +also heals a dropped hint. + +Call-to-Play is stored as bounded per-author slices. The core, not the UI, +creates event/call nonces and timestamps. Wire events contain a typed `CallId`, +event ID, timestamp, and action; author identity comes from the pinned responder +and the display name belongs to the author snapshot. Whole snapshots replace a +newer revision from the same runtime session. Participant events may be retained +but remain hidden until the call creator's direct author slice is present; no +event push, acknowledgement, union merge, relay, or tombstone path exists. ### Download Pipeline When the UI asks to download a game: -1. The UI first issues `PeerCommand::GetGame` for a new download, or - `PeerCommand::FetchLatestFromPeers` for an update that must bypass local - archives. The selected peers are queried via - `request_game_details_from_peer`, and their file manifests are merged inside - `PeerGameDB`. -2. Once the UI receives `PeerEvent::GotGameFiles`, it requests the download by - game ID only. The peer core validates every source manifest before consensus, - chooses the complete authoritative description, and constructs one - root-confined `ValidatedDownloadManifest` before any filesystem mutation. +1. The UI submits only the game ID. The peer core loads that game's immutable + `CatalogContentManifest` and constructs one root-confined + `ValidatedDownloadManifest` before any filesystem mutation. Remote file + descriptions do not choose local paths, sizes, chunks, or hashes. +2. Source selection requires a peer to advertise the exact catalog + `(game_id, content_id)` pair. Every chunk request repeats that `ContentId`, a + typed canonical catalog path, and the exact catalog range; a version string + alone never makes a peer eligible. 3. `download_game_files` recovers any earlier attempt, parks an old `version.ini` as `.version.ini.discarded`, and durably journals the exact old - and proposed downloader-owned file sets before preparing non-sentinel files. - It then emits `PeerEvent::DownloadGameFilesBegin` and builds a per-peer plan - (`build_peer_plans`) that round-robins file chunks across the available peers - that advertise the latest version. + and proposed downloader-owned file sets and their catalog content IDs before + preparing non-sentinel files. It then emits + `PeerEvent::DownloadGameFilesBegin` and builds a per-peer plan + (`build_peer_plans`) that round-robins catalog chunks across the eligible + peers. 4. Each plan is executed in its own task (`download_from_peer`). Chunk requests - use per-chunk QUIC streams and write into pre-created files. The chunk writer - keeps existing data intact and only truncates when we intentionally fall back - to a full file transfer, which prevents corruption when multiple peers fill - different regions of the same file. + use per-chunk QUIC streams and write into pre-created files. Every chunk, + including `version.ini`, must have the catalog length and BLAKE3 digest. One + absolute ten-minute deadline covers opening, requesting, receiving, and the + post-receive checks for each ordinary chunk. 5. `DownloadProgressTracker` samples byte counters, transfer speed, and the number of unique peers that are actively streaming chunks. The Tauri UI sees those values together through the regular download-progress event. -6. `version.ini` chunks are buffered in memory. After transfer, paths owned by +6. Integrity failures quarantine the runtime-local `(PeerId, content_id)` pair; + changing the peer's address does not clear it. Transport failures remain + retryable and do not create durable trust state. A failed chunk is tried at + most once against every distinct eligible peer identity, with no separate + numeric retry cap. +7. `version.ini` chunks are buffered in memory. After transfer, paths owned by the previous successful download but absent from the new manifest are removed. Payload files and their directories are synced before the new sentinel is committed last via `.version.ini.tmp` followed by an atomic rename. A sentinel rename whose directory sync fails leaves ownership pending - for recovery instead of being reported as a durable success. Transfer - failures are accumulated and retried (up to `MAX_RETRY_COUNT`) via - `retry_failed_chunks`. -7. Failure, cancellation, and startup recovery use the journal to remove only + for recovery instead of being reported as a durable success. +8. Failure, cancellation, and startup recovery use the journal to remove only exact downloader-owned files. Unknown user files, `local/`, and install transaction state are preserved. A regular `version.ini` beside a pending journal proves that the final rename landed; otherwise recovery aborts the incomplete payload. -8. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished` is +9. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished` is emitted and the peer auto-runs the install transaction. +The no-transfer local shortcut requires both a catalog-matching local sentinel +and settled ownership whose committed content ID exactly matches the requested +catalog manifest. A version string or pre-content-ID ownership record alone is +never treated as verified local content. + ### Streamed Install Pipeline Low-disk installs use `PeerCommand::StreamInstallGame` instead of the normal -archive download pipeline. The peer core owns the whole operation: it refreshes -file metadata from catalog-version peers, runs the same majority file-size -validation used by normal downloads, selects a validated peer list, and emits -the regular download/install lifecycle events while streaming archive-expanded -bytes directly into a `StreamedInstallTransaction`. +archive download pipeline. The peer core loads the catalog's extracted-file +manifest, rejects games that do not have one, selects sources advertising the +exact catalog `ContentId`, and emits the regular download/install lifecycle +events while streaming archive-expanded bytes directly into an isolated +`StreamedInstallTransaction`. Every Stream Install control-frame path, including +archive names, is a `CanonicalCatalogPath`; malformed framing is distinct from +an explicit sender error. + +The sender admits Stream Install only when its catalog authorizes the feature +and the direct regular root `.eti` set exactly matches the catalog archive set; +missing, extra, or unsupported archives are rejected before provider work +begins. The receiver independently requires the exact archive set and the +complete catalog path/kind/size set, hashes every regular extracted file with +BLAKE3 while writing, and rejects any missing, unknown, repeated, or mismatched +file. Repeated directory entries are harmless and may appear across archives, +but a regular extracted path may appear only once globally. The sender-side `StreamInstallProvider` writes control and chunk frames through a cancellable `StreamInstallFrameSink`. If the QUIC writer fails because the @@ -116,7 +193,10 @@ bounded frame channel and lets the transfer guard drop normally. Each failed peer attempt rolls back its staging directory before trying the next validated peer. A transaction that created a previously missing game root removes that root again when rollback leaves it empty. Once staging has been -renamed to `local/`, post-promote intent or launch-settings cleanup failures are +fully verified, the account, language, and persona settings are rewritten in +that staging tree before it is promoted to `local/`. The one-shot settings +marker is written only after successful promotion; if that marker write fails, +first play safely retries the rewrite. Post-promote intent cleanup failures are logged for startup recovery rather than reported as a failed install. `PeerCommand::CancelDownload` cancels the tracked download token for an active @@ -135,15 +215,22 @@ directories marked by `.lanspread_owned`. Startup recovery combines the recorded intent with the observed filesystem state and only deletes reserved directories when intent or marker ownership proves they belong to Lanspread. -Download provenance is stored separately at -`games//download_ownership.json` in the peer state directory. It is -bound to the canonical configured games directory so switching library roots -cannot make an old record authorize deletion in a different tree. Downloaded- +Download provenance is stored separately under +`games//download_ownership/v1-/record.json` in the peer +state directory. Each namespace is derived from the canonical configured games +directory, while the record retains the full lossless root identity for +collision and binding checks. Switching roots therefore preserves independent +recovery and removal authority for the same game ID in each tree. Downloaded- file removal is deliberately separate from uninstall: it refuses installed or in-flight roots, journals an empty pending generation, and deletes only the regular sentinel plus paths proven by the last committed ownership set. Unknown files, directories, and the game root remain untouched. +Ownership schema 2 binds committed and pending file generations to their exact +catalog content IDs. A former singleton ownership record is migrated once into +its derived root namespace using copy-first durable publication; it is not kept +as a second live format. Split or conflicting migration evidence fails closed. + Legacy launcher-owned files in game directories are migrated by a dedicated pre-start phase. Normal install, recovery, scan, and transfer paths use only the configured state directory for launcher-owned metadata. @@ -154,14 +241,15 @@ The Tauri application embeds this crate in `crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs`: - `LanSpreadState` holds onto the peer control channel, the latest aggregated - `GameDB`, per-game operation state, the catalog set, and the user-selected - game directory. + `GameDB`, per-game operation state, the immutable catalog bundle, and the + user-selected game directory. - The Tauri commands (`request_games`, `install_game`, `update_game`, `remove_downloaded_game`, and `update_game_directory`) translate UI actions - into `PeerCommand`s. In particular, `update_game_directory` validates the - filesystem path before storing it, loads the bundled catalog on first use, - kicks off the peer runtime on demand, and mirrors the installed/uninstalled - state into the UI-facing database. + into `PeerCommand`s. Tauri loads and validates the packaged `game.db` plus + companion manifests once during setup; `update_game_directory` validates the + filesystem path before storing it, starts the peer runtime on demand with that + same bundle, and mirrors installed/uninstalled state into the separate + UI-facing database. - A background task consumes `PeerEvent`s and fans them out to the front-end via Tauri publish/subscribe events (`games-list-updated`, `game-download-*`, `game-install-*`, `game-uninstall-*`, `peer-*`). The Tauri crate now only @@ -170,14 +258,17 @@ The Tauri application embeds this crate in ## Security & Operational Notes -- All QUIC connections are TLS encrypted; the shipped certificates are suitable - for local-network trust but should be rotated for production deployments. -- Peer discovery is restricted to the local link via mDNS. -- Long-running blocking mDNS calls are isolated on dedicated threads which keeps - the async runtime responsive even when discovery takes a long time. -- File writes are chunk-safe: partial chunk downloads open files without - truncating existing data, and root-level `version.ini` is written only after - the rest of the download has succeeded. +- Outbound TLS authenticates the exact responder endpoint. Inbound hints carry + no authority, state snapshots are accepted only from a pinned pull, and file + requests are admitted against the sender's local catalog and exact + `ContentId`. There is no address-only dial or repository-shared certificate. +- mDNS is a link-local source of bounded candidates, not roster authority. A + candidate reaches peer/UI/library state only after the pinned outbound + handshake commits its still-current negotiation lease. +- Long-running blocking mDNS calls are isolated on an owned worker thread whose + shutdown is explicitly joined. +- File writes are catalog-verified per chunk, and root-level `version.ini` is + buffered and written only after the rest of the download has succeeded. ## Known Limitations @@ -185,7 +276,7 @@ The Tauri application embeds this crate in If the UI needs to surface titles that only exist locally, additional merging with the locally scanned `GameDB` will be required. - The download planner uses a simple round-robin and does not yet take per-peer - throughput or failures into account when distributing work. + throughput into account when distributing initial work. Refer to the source (particularly `src/lib.rs`) for the exact message shapes and state machines. diff --git a/design/launcher/SPEC.md b/design/launcher/SPEC.md index be537f1..f6a65ba 100644 --- a/design/launcher/SPEC.md +++ b/design/launcher/SPEC.md @@ -1,5 +1,8 @@ # Handoff: SoftLAN Launcher redesign + + + A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, replacing the current basic UI with a Steam-inspired dark layout that keeps high usability while adding cover art, state-coded actions, a game-detail overlay, @@ -945,20 +948,24 @@ also seeds a few representative calls on mount (a live call with chat, a fresh call you started, a scheduled call whose check-in window just opened, and one scheduled for later collecting RSVPs). -The production launcher uses the peer's existing QUIC control channel. Each -create, response, RSVP, chat, leave, cancel, start, or deadline-extension action -is an immutable, uniquely identified event. Connected peers receive new events -immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event -history so a late joiner reconstructs every event and chat message for active -calls. Running and Cancelled calls retain their complete history for 15 minutes -so late joiners can see the outcome, roster, and chat, then compact to a Start -or Cancel tombstone for the rest of the peer session. Unresolved calls are -removed after the five-minute post-deadline recovery period. The frontend -reducer turns that event history into the `Nomination` state above, derives -time-based phase changes locally, and prunes retired raw events. Stable peer IDs -identify actors and enforce creator controls; `settings.username` is only the -display name. These deadlines use event wall-clock timestamps, so LAN clocks are -assumed to be reasonably close; no clock-synchronization protocol is attempted. +The production launcher sends only local intents over the app boundary. The peer +core generates each immutable event's call/event nonces and timestamp, then +publishes a bounded revision-change hint to connected peers. A hint is only a +liveness optimization: receivers perform an authenticated, identity-pinned +`Hello` pull and replace that author's complete bounded slice. Peers do not +relay another author's history, so a late joiner must discover, pin, and pull +every live author before its wholesale view contains the union of creator, +participant, and chat events. + +Running and Cancelled calls retain their complete author-owned events for 15 +minutes so late joiners can see the outcome, roster, and chat, then expire from +the view. Unresolved calls are removed after the five-minute post-deadline +recovery period. The frontend reducer derives the `Nomination` state and +time-based phase changes from each full replacement. Stable peer IDs identify +authors and enforce creator controls; `settings.username` is a bounded display +name that can update an author's snapshot without creating an event. These +deadlines use event wall-clock timestamps, so LAN clocks are assumed to be +reasonably close; no clock-synchronization protocol is attempted. ---