diff --git a/docs/CONTROL_PROTOCOL.md b/docs/CONTROL_PROTOCOL.md index 7a827ab..f913b49 100644 --- a/docs/CONTROL_PROTOCOL.md +++ b/docs/CONTROL_PROTOCOL.md @@ -86,7 +86,7 @@ Player <-> Host requests and responses (JSON), one at a time, in order A Coordinator that tries to sit in the middle would have to run two handshakes, each with its own `h`; the Host's signature covers only the Host's `h`, so the Player rejects it. The proofs of one channel are useless on another. Messages are split into frames of ≤ 16 KiB of plaintext (+ a flag byte + the 16-byte tag, so ≤ 20 KiB with the connection number), reassembled up to 8 MiB; a frame that does not authenticate ends the channel. -Application messages: requests ≤ 4096 bytes, strict JSON with unknown fields refused, at most 60 requests per channel, 300 s per channel at the Host (idle 45 s). +Application messages: standard control requests ≤ 4096 bytes (4 KiB, `MAX_CONTROL_REQUEST_BYTES`), while bulk transfer requests (`transfer_offer` and `transfer_chunk`) support bounded payloads up to 4 MiB (`MAX_TRANSFER_REQUEST_BYTES`). Strict JSON with unknown fields refused, at most 60 requests per channel, 300 s per channel at the Host (idle 45 s). ## 5. Application messages @@ -98,8 +98,12 @@ Application messages: requests ≤ 4096 bytes, strict JSON with unknown fields r | `list_characters` | `characters{characters}` | the characters of the player's account (opaque per-connection handles; the realm's own numbers never leave the Host) | | `claim{token}` | `claimed{character_id, sha256, payload, collections}` | export one character (section 7) | | `claim_ack{character_id}` | `done` | the player stored it; the claim is final | -| `transfer_character{character_id, revision, sha256, payload, collections}` | `transferred{character_id, local_guid, session_id}` | transactional remote character import/projection (Phase 12.1) | -| `allocate_relay` | `relay_allocated{relay_host, auth_port, world_port, token, expires_at}` | dynamic game relay allocation for NAT traversal (Phase 13) | +| `transfer_offer{transfer_id, character_id, canonical_revision, content_hash, total_size, collections}` | `transfer_ready{transfer_id, accepted_offset}` | initiate or resume remote character transfer (Phase 12.1) | +| `transfer_chunk{transfer_id, offset, data}` | `transfer_progress{transfer_id, received_bytes, total_bytes}` | transmit chunk of encoded payload (≤ 4 MiB) | +| `transfer_status{transfer_id}` | `transfer_progress{transfer_id, received_bytes, total_bytes}` | check upload progress / resume offset | +| `transfer_commit{transfer_id}` | `transferred{character_id, local_guid, session_id}` | validate, import/project into realm, arm session | +| `transfer_ack{transfer_id, character_id}` | `done` | acknowledge successful transfer and arming | +| `allocate_relay{player_id}` | `relay_allocated{relay_host, auth_port, world_port, token, expires_at}` | dynamic game relay allocation for NAT traversal (Phase 13) | | `route` | `route_info{address}` | the address the game client can use right now, if the owner stated one | Errors (`error{code,message}`): `unsupported_version`, `invalid`, `provisioning_off`, `no_account`, `not_at_character_select`, `not_yours`, `already_claimed`, `not_eligible`, `wrong_credentials`, `account_taken`, `rate_limited`, `unavailable`, `incompatible`, `relay_unavailable`. @@ -144,15 +148,23 @@ its profile, and only then sends `claim_ack`; the Host marks the binding `Portab When a player selects a portable character that exists in their Player Manager but does not yet exist on the remote realm, the Player Manager performs an automated, transactional transfer over the encrypted Noise channel: 1. **Preflight Compatibility Check**: The Player Manager checks the canonical character against the target realm's advertised capabilities (Phase 7/8). If incompatible, the transfer is refused immediately before any network mutation. -2. **Payload Transfer**: Inside the end-to-end encrypted Noise channel, the Player Manager sends `transfer_character{character_id, revision, sha256, payload, collections}`. The Coordinator sees only opaque ciphertext. -3. **Host Validation**: The Host validates: - - PlayerIdentity proof of ownership; - - `PortableCharacter` format version, schema integrity, and SHA-256 payload hash; - - Target realm account mapping for this player; - - Level projection policy (e.g. projecting level 80 down to realm cap 60 via Phase-8 oracle); - - Idempotency: if this exact canonical revision was already imported, the existing working copy GUID and armed session ID are returned immediately without duplicating the character; - - Revision update: if a newer canonical revision is transferred, the Host updates the existing working copy rather than creating a duplicate character. -4. **Outcome & Session Arming**: On successful import/projection, the Host returns `transferred{character_id, local_guid, session_id}`. The session engine arms the session for game login. +2. **Transfer Offer (`TransferOffer`)**: Inside the end-to-end encrypted Noise channel, the Player Manager sends `transfer_offer{transfer_id, character_id, canonical_revision, content_hash, total_size, collections}`. + - The Host validates the PlayerIdentity, character ownership, format revision, hash format, size limit (≤ 4 MiB, `MAX_TRANSFER_REQUEST_BYTES`), and collection counts (≤ 100,000 items). + - If the exact revision was already committed or an active transfer session exists, the Host responds with `transfer_ready{transfer_id, accepted_offset}`. +3. **Payload Transfer & Chunking (`TransferChunk` / `TransferStatus`)**: + - The Player sends the payload in bounded chunks via `transfer_chunk{transfer_id, offset, data}`. + - The Host records incoming chunks, tracks received bytes, and replies with `transfer_progress{transfer_id, received_bytes, total_bytes}`. + - If a connection is interrupted, the Player can query progress via `transfer_status{transfer_id}` and resume from `accepted_offset`. +4. **Validation & Atomic Commit (`TransferCommit`)**: + - Once all chunks arrive, the Player sends `transfer_commit{transfer_id}`. + - The Host verifies the SHA-256 content hash of the assembled payload against the offer. + - The Host runs the level projection engine (e.g. projecting level 80 down to realm cap 60 via Phase-8 oracle). + - The Host imports the character into the realm's characters database, updates/creates the working copy mapping, and arms the session. + - The Host returns `transferred{character_id, local_guid, session_id}`. +5. **Acknowledgement (`TransferAck`)**: + - The Player Manager receives `transferred`, verifies local session arming, and returns `transfer_ack{transfer_id, character_id}`. + - The Host finalizes the transfer transaction and responds with `done`. + - The session engine is now fully armed for game login. ## 7.2 Game Relay Allocation (Phase 13) diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md index 919dd17..5546fd0 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -84,14 +84,14 @@ ssh ubuntu@ ## Firewall policy -UFW, default deny incoming. Currently allowed: OpenSSH, 80/tcp, 443/tcp. +UFW, default deny incoming. Currently allowed: OpenSSH (22/tcp), 80/tcp, 443/tcp, and the Game Relay port pool 40000-43999/tcp. ```bash sudo ufw status verbose ``` Docker-published ports bypass UFW (see VPS_BASELINE.md). Check `sudo docker ps` and `sudo ss -tulnH` after every -deployment; the only public listeners must be 22, 80, 443. +deployment; the only public listeners must be 22, 80, 443, and Game Relay listeners in 40000–43999/tcp. fail2ban protects SSH (default `sshd` jail): diff --git a/docs/VPS_BASELINE.md b/docs/VPS_BASELINE.md index 559220e..0dcf6f6 100644 --- a/docs/VPS_BASELINE.md +++ b/docs/VPS_BASELINE.md @@ -29,12 +29,10 @@ Date configured: 2026-10-06 (UTC) - Base packages: curl, wget, git, ca-certificates, gnupg, jq, unzip, htop, ufw, fail2ban, unattended-upgrades. - Hostname `coa-infra-01`, timezone UTC. - SSH: key-only, no root login (details below). -- UFW: default deny incoming, allow outgoing; only 22/tcp, 80/tcp, 443/tcp allowed. +- UFW: default deny incoming, allow outgoing; 22/tcp, 80/tcp, 443/tcp, and the Game Relay dynamic pool 40000-43999/tcp allowed. - fail2ban: default `sshd` jail only. -- unattended-upgrades: enabled for the `-security` pockets (Ubuntu, ESM apps, ESM infra); no automatic reboot, - no release upgrades. -- Docker Engine from the official Docker apt repository (docker-ce, docker-ce-cli, containerd.io, - docker-buildx-plugin, docker-compose-plugin). +- unattended-upgrades: enabled for the `-security` pockets (Ubuntu, ESM apps, ESM infra); no automatic reboot, no release upgrades. +- Docker Engine from the official Docker apt repository (docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, docker-compose-plugin). - `/opt/coa` layout, Caddy running as the future ingress, PostgreSQL definition prepared but not started. ## Public ports (verified from an external machine) @@ -73,14 +71,15 @@ Everything else is closed, including 5432 (Postgres), 8080 (Registry internal), ``` Default: deny (incoming), allow (outgoing) -22/tcp OpenSSH ALLOW IN (v4+v6) -80/tcp ALLOW IN (v4+v6) -443/tcp ALLOW IN (v4+v6) +22/tcp OpenSSH ALLOW IN (v4+v6) +80/tcp ALLOW IN (v4+v6) +443/tcp ALLOW IN (v4+v6) +40000:43999/tcp ALLOW IN (v4+v6) ``` **Docker caveat:** ports published by Docker (`ports:` in a compose file) are inserted into iptables ahead of UFW and are reachable from the internet even if UFW has no rule for them. Rule for this host: never publish a port other than -Caddy's 80/443; services talk to each other over Docker networks, and anything that must be reachable only locally is +Caddy's 80/443 and the dedicated Game Relay pool `40000:43999/tcp`; services talk to each other over Docker networks, and anything that must be reachable only locally is published as `127.0.0.1::`. ## Docker diff --git a/services/coa-control-e2e/Cargo.lock b/services/coa-control-e2e/Cargo.lock index 0bb0b2c..d335b00 100644 --- a/services/coa-control-e2e/Cargo.lock +++ b/services/coa-control-e2e/Cargo.lock @@ -356,7 +356,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.6" +version = "0.6.7" dependencies = [ "base64 0.22.1", "chrono", diff --git a/services/coa-coordinator/tests/coord.rs b/services/coa-coordinator/tests/coord.rs index 857e140..b13f5cb 100644 --- a/services/coa-coordinator/tests/coord.rs +++ b/services/coa-coordinator/tests/coord.rs @@ -108,8 +108,8 @@ fn routes_both_ways_by_connection_number_and_never_interprets() { let mut h = host(&s, realm, &hk); let mut p1 = player(&s, realm, &key(10)); let mut p2 = player(&s, realm, &key(11)); - let Frame::Open { conn: c1 } = text(&mut h) else { panic!("open 1") }; - let Frame::Open { conn: c2 } = text(&mut h) else { panic!("open 2") }; + let Frame::Open { conn: c1, .. } = text(&mut h) else { panic!("open 1") }; + let Frame::Open { conn: c2, .. } = text(&mut h) else { panic!("open 2") }; assert_ne!(c1, c2); assert!(matches!(text(&mut p1), Frame::PlayerReady { conn, .. } if conn == c1)); assert!(matches!(text(&mut p2), Frame::PlayerReady { conn, .. } if conn == c2)); diff --git a/services/coa-relay/tests/relay.rs b/services/coa-relay/tests/relay.rs index 5c0e097..fca137c 100644 --- a/services/coa-relay/tests/relay.rs +++ b/services/coa-relay/tests/relay.rs @@ -179,12 +179,12 @@ async fn test_relay_host_auth_and_allocation_and_game_traffic() { resp }); - // Host receives Connect for WORLD (skipping any Close from stream 1) + // Host receives Connect for WORLD (skipping any Close/Reset from stream 1) let (world_sid, world_target) = loop { let msg = ws_src.next().await.unwrap().unwrap(); match serde_json::from_str::(msg.to_text().unwrap()).unwrap() { TunnelMsg::Connect { stream_id, target, .. } => break (stream_id, target), - TunnelMsg::Close { .. } => continue, + TunnelMsg::Close { .. } | TunnelMsg::Reset { .. } => continue, other => panic!("unexpected msg awaiting Connect: {other:?}"), } }; @@ -193,12 +193,12 @@ async fn test_relay_host_auth_and_allocation_and_game_traffic() { // Host acknowledges ws_sink.send(Message::Text(serde_json::to_string(&TunnelMsg::ConnectOk { stream_id: world_sid }).unwrap().into())).await.unwrap(); - // Host receives world data (skipping any Close from stream 1) + // Host receives world data (skipping any Close/Reset from stream 1) let world_chunk = loop { let msg = ws_src.next().await.unwrap().unwrap(); match serde_json::from_str::(msg.to_text().unwrap()).unwrap() { TunnelMsg::Data { stream_id, chunk } if stream_id == world_sid => break chunk, - TunnelMsg::Close { .. } => continue, + TunnelMsg::Close { .. } | TunnelMsg::Reset { .. } => continue, other => panic!("unexpected msg awaiting Data: {other:?}"), } };