docs(peer): document authenticated sharing architecture

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)
This commit is contained in:
2026-08-10 14:05:00 +02:00
parent 4a1b08db98
commit e0eafa6e33
4 changed files with 465 additions and 260 deletions
+21 -1
View File
@@ -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.
+254 -167
View File
@@ -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<GameAvailability> }`, 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<GameSummary> } }`
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/<game_id>/install_intent.json` in the configured state directory is the
atomic per-game intent log.
- `games/<game_id>/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/<game_id>/download_ownership/v1-<root_digest>/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/<game_id>.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.
+169 -78
View File
@@ -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/<game_id>/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/<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.
@@ -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.
+21 -14
View File
@@ -1,5 +1,8 @@
# Handoff: SoftLAN Launcher redesign
<!-- Exact layout diagrams and CSS literals intentionally remain unwrapped. -->
<!-- rumdl-disable MD013 -->
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.
---