Update user and developer documentation for the protocol-8 system: persistent SPKI-derived identities, exact catalog ContentId authority, pinned responder pulls, structured runtime ownership, direct-author Call to Play, and the global local-network sharing switch. Remove active descriptions of repository-wide certificates, pushed deltas, relayed histories, and metadata consensus. Keep operational and UI boundaries aligned with the implementation, including the fail-closed production catalog gate. Test Plan: - `just fmt` (passed) - `git diff --cached --check` (passed)
283 lines
17 KiB
Markdown
283 lines
17 KiB
Markdown
# lanspread-peer
|
||
|
||
`lanspread-peer` is the networking runtime that lets Lanspread nodes find each
|
||
other on the local network, exchange library metadata, and transfer game files.
|
||
It is designed to run headless – other crates (most notably
|
||
`lanspread-tauri-deno-ts`) embed it and drive it through a channel-based API.
|
||
|
||
## Runtime Overview
|
||
|
||
- `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 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:
|
||
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 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 bounded `Ping`, `Hello`, change-hint,
|
||
exact-content chunk, and Stream Install requests.
|
||
2. **Discovery loop** (`run_peer_discovery`) – uses the `lanspread-mdns` helper
|
||
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`. 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
|
||
`version.ini` sentinel exists; `local/` being a directory is the install signal.
|
||
|
||
## Networking and File Transfer
|
||
|
||
- 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_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 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 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. 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. 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.
|
||
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.
|
||
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 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
|
||
receiver cancelled or disconnected, the sink wakes any producer blocked on the
|
||
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
|
||
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
|
||
transfer. The transfer task remains responsible for clearing
|
||
`active_operations`, discarding partial payload files, and refreshing the
|
||
settled local snapshot, so the UI continues to treat active-operation snapshots
|
||
as the single source of truth for whether a download is still running.
|
||
|
||
### Install Transactions
|
||
|
||
Install, update, uninstall, and install-side startup recovery live under
|
||
`src/install/`. Install-side operation intent is stored atomically under the
|
||
configured peer state directory, at `games/<game_id>/install_intent.json`. Game
|
||
roots still use Lanspread-owned `.local.installing/` and `.local.backup/`
|
||
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 under
|
||
`games/<game_id>/download_ownership/v1-<root_digest>/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.
|
||
|
||
## Integration with `lanspread-tauri-deno-ts`
|
||
|
||
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 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. 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
|
||
provides the unrar sidecar through the injected `Unpacker`; rollback and
|
||
cleanup live in the peer transaction code.
|
||
|
||
## Security & Operational Notes
|
||
|
||
- 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
|
||
|
||
- `PeerGameDB` currently models the latest metadata that other peers advertise.
|
||
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 into account when distributing initial work.
|
||
|
||
Refer to the source (particularly `src/lib.rs`) for the exact message shapes and
|
||
state machines.
|