Compare commits
6 Commits
921cc42fd4
...
temp/pc-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1de5a8f3e6 | ||
|
|
ed5cb14b30 | ||
|
|
c4a8501635 | ||
|
|
56ac74e916 | ||
|
|
b94aac71f8 | ||
|
|
7337c27898 |
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Read(//g/Coding/code2/**)",
|
||||||
|
"Bash(python -m py_compile server/server.py)",
|
||||||
|
"Bash(node --check client/client.js)",
|
||||||
|
"Bash(node --check client/protocol.js)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
241
CLAUDE.md
Normal file
241
CLAUDE.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This is a real-time multiplayer Snake game using Python 3 (server) and vanilla JavaScript (web client), communicating via WebTransport datagrams (HTTP/3) for low-latency gameplay. The game features continuous persistent gameplay, unique collision mechanics (head-blocks-and-tail-shrinks instead of death), and sophisticated UDP packet handling with compression and partitioning.
|
||||||
|
|
||||||
|
## Running the Server
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
```bash
|
||||||
|
# Create and activate virtual environment
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Modes
|
||||||
|
|
||||||
|
The server supports multiple transport modes via `MODE` environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In-memory mode (testing, no network)
|
||||||
|
MODE=mem python run.py
|
||||||
|
|
||||||
|
# WebTransport mode (HTTP/3, default for production)
|
||||||
|
MODE=wt QUIC_CERT=cert.pem QUIC_KEY=key.pem python run.py
|
||||||
|
|
||||||
|
# QUIC datagram mode only
|
||||||
|
MODE=quic QUIC_CERT=cert.pem QUIC_KEY=key.pem python run.py
|
||||||
|
|
||||||
|
# Combined mode (both WebTransport and QUIC)
|
||||||
|
MODE=net WT_PORT=4433 QUIC_PORT=4443 QUIC_CERT=cert.pem QUIC_KEY=key.pem python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
- `MODE`: Transport mode (mem|wt|quic|net)
|
||||||
|
- `QUIC_CERT` / `QUIC_KEY`: TLS certificate and key paths (required for wt/quic/net)
|
||||||
|
- `WT_CERT` / `WT_KEY`: WebTransport-specific cert/key (falls back to QUIC_* if not set)
|
||||||
|
- `WT_HOST` / `WT_PORT`: WebTransport server host/port (default: 0.0.0.0:4433)
|
||||||
|
- `QUIC_HOST` / `QUIC_PORT`: QUIC server host/port (default: 0.0.0.0:4433, or 4443 in net mode)
|
||||||
|
- `STATIC`: Enable static HTTPS server for client (default: 1 in wt mode)
|
||||||
|
- `STATIC_HOST` / `STATIC_PORT` / `STATIC_ROOT`: Static server settings (default: same as WT host, port 8443, root "client")
|
||||||
|
- `LOG_LEVEL`: Logging level (DEBUG|INFO|WARNING|ERROR)
|
||||||
|
- `RUN_SECONDS`: Optional timeout for server shutdown (testing)
|
||||||
|
|
||||||
|
### Help
|
||||||
|
```bash
|
||||||
|
python run.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Server Architecture (Python 3 + asyncio)
|
||||||
|
|
||||||
|
**Core Components:**
|
||||||
|
|
||||||
|
1. **server/server.py** (`GameServer`): Authoritative game server
|
||||||
|
- Fixed tick rate simulation (default 10 TPS, configurable 5-30)
|
||||||
|
- Manages player sessions, snakes, apples, collision detection
|
||||||
|
- Handles join/spawn logic, input processing, state broadcasting
|
||||||
|
- Automatically partitions large updates across multiple datagrams (<1200 bytes each to avoid IP fragmentation)
|
||||||
|
|
||||||
|
2. **server/model.py**: Game state entities
|
||||||
|
- `GameState`: Field grid, snakes dict, apples list, occupancy map
|
||||||
|
- `Snake`: Deque-based body representation, input buffer (capacity 3), blocked flag
|
||||||
|
- `PlayerSession`: Per-player metadata (id, name, color, peer handle)
|
||||||
|
|
||||||
|
3. **server/protocol.py**: Wire protocol implementation
|
||||||
|
- Packet types: JOIN, JOIN_ACK, JOIN_DENY, INPUT, INPUT_BROADCAST, STATE_DELTA, STATE_FULL, PART, CONFIG_UPDATE
|
||||||
|
- TLV body encoding: 2-bit direction streams, RLE compression, chunked variants for oversized snakes
|
||||||
|
- QUIC varint encoding for efficient integer serialization
|
||||||
|
- Sequence number handling with wraparound logic (16-bit)
|
||||||
|
|
||||||
|
4. **server/config.py** (`ServerConfig`): Server configuration dataclass with validation
|
||||||
|
|
||||||
|
5. **Transport layer** (pluggable):
|
||||||
|
- `server/transport.py`: Abstract `DatagramServerTransport` interface
|
||||||
|
- `server/webtransport_server.py`: WebTransport (HTTP/3) implementation using aioquic
|
||||||
|
- `server/quic_transport.py`: Raw QUIC datagram implementation
|
||||||
|
- `server/multi_transport.py`: Multiplexer for running multiple transports concurrently
|
||||||
|
- `server/static_server.py`: Optional HTTPS static file server for serving client assets
|
||||||
|
|
||||||
|
### Client Architecture (Vanilla JavaScript)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `client/index.html`: Main HTML page with canvas and UI controls
|
||||||
|
- `client/client.js`: Game client, WebTransport connection, input handling, rendering
|
||||||
|
- `client/protocol.js`: Wire protocol parsing/building (mirrors server protocol)
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- WebTransport datagram API for unreliable, unordered messaging
|
||||||
|
- Canvas-based rendering with auto-scaling grid
|
||||||
|
- Input buffering (up to 3 direction changes) with 180° turn filtering
|
||||||
|
- State update handling: STATE_FULL (initial/recovery), STATE_DELTA (per-tick), PART (fragmented)
|
||||||
|
- Packet sequence tracking to discard late/out-of-order updates
|
||||||
|
|
||||||
|
### Game Mechanics (Unique Collision System)
|
||||||
|
|
||||||
|
**No Death on Collision:**
|
||||||
|
- When a snake's head hits an obstacle (wall, self, or another snake), the head stays in place (blocked state)
|
||||||
|
- While blocked, the tail shrinks by 1 cell per tick until the player turns to a free direction
|
||||||
|
- Minimum length is 1 (head only); snake cannot disappear while player is connected
|
||||||
|
|
||||||
|
**Input Buffer Rules:**
|
||||||
|
- Client-side buffer holds up to 3 upcoming direction changes
|
||||||
|
- Opposite direction to last buffered input replaces the last entry (not appended)
|
||||||
|
- Consecutive duplicates are dropped
|
||||||
|
- Overflow policy: replace last element
|
||||||
|
- Server consumption: At each tick, consume at most one input; skip 180° turns when length > 1
|
||||||
|
|
||||||
|
**Apples & Scoring:**
|
||||||
|
- Displayed as current snake length (not separate score)
|
||||||
|
- Eating an apple grows snake by 1 immediately
|
||||||
|
- Target apple count: max(3, min(apples_cap, connected_players × apples_per_snake))
|
||||||
|
- When 0 players remain, pre-populate field with exactly 3 apples
|
||||||
|
|
||||||
|
**Spawning:**
|
||||||
|
- Try to allocate a 3-cell straight strip in a free direction
|
||||||
|
- Fallback: spawn length 1 (head only) at any free cell
|
||||||
|
- Deny join if no free cells available
|
||||||
|
|
||||||
|
### Protocol Details
|
||||||
|
|
||||||
|
**Packet Structure:**
|
||||||
|
```
|
||||||
|
Header (all packets):
|
||||||
|
ver (u8) | type (u8) | flags (u8) | seq (u16 big-endian) | [tick (u16 optional)]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sequence Numbers:**
|
||||||
|
- 16-bit wraparound with half-range window comparison: `is_newer(a,b) = ((a-b) & 0xFFFF) < 0x8000`
|
||||||
|
- Clients discard packets with older seq than last applied per sender
|
||||||
|
|
||||||
|
**State Encoding:**
|
||||||
|
- **STATE_FULL**: Complete snapshot (on join, periodic recovery); includes all snakes + apples
|
||||||
|
- Per-snake: id (u8), len (u16), head (x,y), body TLV (BODY_2BIT or BODY_RLE)
|
||||||
|
- **STATE_DELTA**: Incremental update (per tick); includes only changed snakes + apple diffs
|
||||||
|
- Per-change: snake_id, flags (head_moved|tail_removed|grew|blocked), direction, new_head coords
|
||||||
|
- **PART**: Multi-part fragmentation for large updates (>1200 bytes)
|
||||||
|
- Fields: update_id (u16), part_index, parts_total, inner_type (STATE_FULL or STATE_DELTA), chunk_payload
|
||||||
|
|
||||||
|
**Body TLV Types:**
|
||||||
|
- `BODY_2BIT (0x00)`: 2-bit direction stream (up=00, right=01, down=10, left=11), packed LSB-first
|
||||||
|
- `BODY_RLE (0x01)`: Run-length encoding (dir u8, count QUIC varint) for straight segments
|
||||||
|
- `BODY_2BIT_CHUNK (0x10)` / `BODY_RLE_CHUNK (0x11)`: Chunked variants for splitting oversized snakes across parts
|
||||||
|
- Chunk header: start_index (u16), dirs_in_chunk (u16)
|
||||||
|
|
||||||
|
**Input Broadcasting:**
|
||||||
|
- Server relays client inputs immediately to all other clients as INPUT_BROADCAST packets
|
||||||
|
- Enables client-side opponent prediction during late/lost state updates
|
||||||
|
- Fields: player_id, input_seq, base_tick, events (rel_tick_offset + direction), optional apply_at_tick
|
||||||
|
|
||||||
|
**Partitioning Strategy:**
|
||||||
|
- Soft limit: 1200 bytes per datagram (avoid fragmentation; MTU ~1280-1500)
|
||||||
|
- Whole-snake-first: pack complete snakes greedily; if a single snake exceeds budget, split using chunked TLV
|
||||||
|
- Apples included only in first part; clients merge across parts using update_id
|
||||||
|
|
||||||
|
## Key Simulation Details
|
||||||
|
|
||||||
|
**Tick Order:**
|
||||||
|
1. Consume at most one valid input per snake (apply 180° rule for length > 1)
|
||||||
|
2. Compute intended head step; if blocked (wall/occupied), set `blocked=True` and keep head stationary
|
||||||
|
3. If blocked: shrink tail by 1 (min length 1)
|
||||||
|
4. If moving into apple: grow by 1 (don't shrink tail that tick) and respawn apple
|
||||||
|
5. Emit per-snake delta and global apple changes
|
||||||
|
|
||||||
|
**Collision Detection:**
|
||||||
|
- Occupancy map tracks all snake cells as (coord) -> (snake_id, index)
|
||||||
|
- Allow moving into own tail if it will vacate (i.e., not growing)
|
||||||
|
- Walls block only when `wrap_edges=False`; wrapped coordinates computed by `_step_from()`
|
||||||
|
|
||||||
|
**180° Turn Handling:**
|
||||||
|
- Checked at consumption time using XOR: `(int(new_dir) ^ int(current_dir)) == 2`
|
||||||
|
- Allowed only when snake length == 1 (head only)
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
- **Testing**: No automated test suite currently exists. Manual testing via in-memory mode (`MODE=mem`) or live WebTransport server.
|
||||||
|
- **Logging**: Use `LOG_LEVEL=DEBUG` for verbose packet-level debugging.
|
||||||
|
- **Field Size**: Default 60×40; protocol supports 3×3 to 255×255 (negotiated in JOIN_ACK).
|
||||||
|
- **Players**: Max 32 concurrent (ids 0-31); names truncated to 16 UTF-8 bytes.
|
||||||
|
- **Colors**: 32-color palette; deterministic mapping from player_id to color_id (0-31).
|
||||||
|
- **Tick Rate Changes**: Server broadcasts CONFIG_UPDATE every 50 ticks; clients apply at next tick boundary.
|
||||||
|
- **Compression**: Optional global DEFLATE mode (handshake-only; requires server restart). Currently defaults to "none".
|
||||||
|
- **Browser Compatibility**: Requires WebTransport API support (latest Firefox/Chrome).
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
**Adding a New Packet Type:**
|
||||||
|
1. Add enum value to `PacketType` in server/protocol.py and client/protocol.js
|
||||||
|
2. Implement builder function in protocol (e.g., `build_foo()`) and parser (e.g., `parse_foo()`)
|
||||||
|
3. Handle in server's `on_datagram()` and client's `readLoop()` switch statements
|
||||||
|
4. Update header packing if tick field is required (pass tick to `pack_header()`)
|
||||||
|
|
||||||
|
**Modifying Simulation Logic:**
|
||||||
|
1. Edit `_simulate_tick()` in server/server.py for per-tick updates
|
||||||
|
2. Update `SnakeDelta` dataclass if new change types are needed
|
||||||
|
3. Adjust `build_state_delta_body()` if encoding changes
|
||||||
|
4. Mirror changes in client rendering logic (client/client.js)
|
||||||
|
|
||||||
|
**Changing Configuration:**
|
||||||
|
1. Update `ServerConfig` dataclass in server/config.py
|
||||||
|
2. Add runtime validation in `validate_runtime()`
|
||||||
|
3. If live-configurable: broadcast via CONFIG_UPDATE packet; otherwise require server restart
|
||||||
|
4. Update JOIN_ACK builder/parser if part of handshake
|
||||||
|
|
||||||
|
## File Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
G:\Coding\code2\
|
||||||
|
├── run.py # Server entrypoint with mode switching
|
||||||
|
├── requirements.txt # Python dependencies (aioquic, cryptography)
|
||||||
|
├── PROJECT_PLAN.md # Detailed design spec (authoritative reference)
|
||||||
|
├── IDEAS.md # Original feature brainstorming
|
||||||
|
├── server/
|
||||||
|
│ ├── server.py # GameServer (tick loop, join/spawn, simulation)
|
||||||
|
│ ├── model.py # GameState, Snake, PlayerSession entities
|
||||||
|
│ ├── protocol.py # Wire protocol (packet builders/parsers, TLV encoding)
|
||||||
|
│ ├── config.py # ServerConfig dataclass
|
||||||
|
│ ├── transport.py # Abstract DatagramServerTransport interface
|
||||||
|
│ ├── webtransport_server.py # HTTP/3 WebTransport implementation
|
||||||
|
│ ├── quic_transport.py # QUIC datagram implementation
|
||||||
|
│ ├── multi_transport.py # Multi-transport multiplexer
|
||||||
|
│ ├── static_server.py # Optional HTTPS static file server
|
||||||
|
│ └── utils.py # Utility functions
|
||||||
|
└── client/
|
||||||
|
├── index.html # Main HTML page
|
||||||
|
├── client.js # Game client (WebTransport, input, rendering)
|
||||||
|
└── protocol.js # Wire protocol (mirrors server protocol.py)
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **PROJECT_PLAN.md**: Comprehensive design document covering protocol, mechanics, limits, testing strategy, and milestones.
|
||||||
|
- **aioquic**: Python QUIC and HTTP/3 library (https://github.com/aiortc/aioquic)
|
||||||
|
- **WebTransport**: W3C spec for low-latency client-server messaging over QUIC (https://w3c.github.io/webtransport/)
|
||||||
177
client/client.js
177
client/client.js
@@ -1,4 +1,4 @@
|
|||||||
import { PacketType, Direction, packHeader, parseHeader, buildJoin, buildInput, parseJoinAck, parseStateFullBody } from './protocol.js';
|
import { PacketType, Direction, packHeader, parseHeader, buildJoin, buildInput, parseJoinAck, parseStateFullBody, parseStateDeltaBody } from './protocol.js';
|
||||||
|
|
||||||
const ui = {
|
const ui = {
|
||||||
url: document.getElementById('url'),
|
url: document.getElementById('url'),
|
||||||
@@ -109,7 +109,79 @@ async function readLoop() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case PacketType.STATE_DELTA: {
|
case PacketType.STATE_DELTA: {
|
||||||
// Minimal: ignore detailed deltas for now; rely on periodic full (good enough for a demo)
|
const delta = parseStateDeltaBody(dv, off);
|
||||||
|
// Apply changes to local snake state
|
||||||
|
for (const ch of delta.changes) {
|
||||||
|
let snake = snakes.get(ch.snakeId);
|
||||||
|
if (!snake) {
|
||||||
|
// New snake appeared; skip for now (will get it in next full update)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update direction (always present)
|
||||||
|
// Note: direction is the current direction, not necessarily the new head direction
|
||||||
|
|
||||||
|
if (ch.headMoved) {
|
||||||
|
// Snake moved: prepend new head position
|
||||||
|
// Compute direction from old head to new head
|
||||||
|
const oldHx = snake.hx;
|
||||||
|
const oldHy = snake.hy;
|
||||||
|
const newHx = ch.newHeadX;
|
||||||
|
const newHy = ch.newHeadY;
|
||||||
|
|
||||||
|
// Determine direction of movement
|
||||||
|
let moveDir = ch.direction;
|
||||||
|
if (newHx === oldHx && newHy === oldHy - 1) moveDir = Direction.UP;
|
||||||
|
else if (newHx === oldHx + 1 && newHy === oldHy) moveDir = Direction.RIGHT;
|
||||||
|
else if (newHx === oldHx && newHy === oldHy + 1) moveDir = Direction.DOWN;
|
||||||
|
else if (newHx === oldHx - 1 && newHy === oldHy) moveDir = Direction.LEFT;
|
||||||
|
|
||||||
|
// Update head position
|
||||||
|
snake.hx = newHx;
|
||||||
|
snake.hy = newHy;
|
||||||
|
|
||||||
|
// If tail was removed (normal move), keep dirs same length by prepending new dir
|
||||||
|
// If grew (ate apple), prepend without removing tail
|
||||||
|
if (ch.grew) {
|
||||||
|
// Growing: add direction to front, don't remove from back
|
||||||
|
snake.dirs = [moveDir, ...snake.dirs];
|
||||||
|
snake.len += 1;
|
||||||
|
} else if (ch.tailRemoved) {
|
||||||
|
// Normal move: head advances, tail shrinks
|
||||||
|
// Keep dirs array same size (represents len-1 directions)
|
||||||
|
// Actually, we need to be careful here about the representation
|
||||||
|
// dirs[i] tells us how to get from cell i to cell i+1
|
||||||
|
// When head moves and tail shrinks, we add new dir at front, remove old from back
|
||||||
|
if (snake.dirs.length > 0) {
|
||||||
|
snake.dirs.pop(); // remove tail direction
|
||||||
|
}
|
||||||
|
snake.dirs = [moveDir, ...snake.dirs];
|
||||||
|
} else {
|
||||||
|
// Head moved but tail didn't remove (shouldn't happen in normal gameplay)
|
||||||
|
snake.dirs = [moveDir, ...snake.dirs];
|
||||||
|
snake.len += 1;
|
||||||
|
}
|
||||||
|
} else if (ch.blocked) {
|
||||||
|
// Snake is blocked: head stayed in place, tail shrunk
|
||||||
|
if (ch.tailRemoved && snake.dirs.length > 0) {
|
||||||
|
snake.dirs.pop(); // remove last direction (tail shrinks)
|
||||||
|
snake.len = Math.max(1, snake.len - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply apple changes
|
||||||
|
const appleSet = new Set(apples.map(a => `${a[0]},${a[1]}`));
|
||||||
|
for (const [x, y] of delta.applesAdded) {
|
||||||
|
appleSet.add(`${x},${y}`);
|
||||||
|
}
|
||||||
|
for (const [x, y] of delta.applesRemoved) {
|
||||||
|
appleSet.delete(`${x},${y}`);
|
||||||
|
}
|
||||||
|
apples = Array.from(appleSet).map(s => {
|
||||||
|
const [x, y] = s.split(',').map(Number);
|
||||||
|
return [x, y];
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: break;
|
default: break;
|
||||||
@@ -121,24 +193,61 @@ async function readLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function connectWT() {
|
async function connectWT() {
|
||||||
|
// Check if WebTransport is available
|
||||||
|
if (typeof WebTransport === 'undefined') {
|
||||||
|
setStatus('ERROR: WebTransport not supported in this browser. Use Chrome/Edge 97+ or Firefox with flag enabled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const url = ui.url.value.trim();
|
const url = ui.url.value.trim();
|
||||||
const hashHex = ui.hash.value.trim();
|
const hashHex = ui.hash.value.trim();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
setStatus('ERROR: Please enter a server URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus('connecting...');
|
setStatus('connecting...');
|
||||||
|
console.log('Connecting to:', url);
|
||||||
|
|
||||||
const opts = {};
|
const opts = {};
|
||||||
if (hashHex) {
|
if (hashHex) {
|
||||||
const bytes = new Uint8Array(hashHex.match(/.{1,2}/g).map(b => parseInt(b,16)));
|
try {
|
||||||
opts.serverCertificateHashes = [{ algorithm: 'sha-256', value: bytes }];
|
const bytes = new Uint8Array(hashHex.match(/.{1,2}/g).map(b => parseInt(b,16)));
|
||||||
|
opts.serverCertificateHashes = [{ algorithm: 'sha-256', value: bytes }];
|
||||||
|
console.log('Using certificate hash:', hashHex);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('ERROR: Invalid certificate hash format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('No certificate hash provided - connection may fail for self-signed certs');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wt = new WebTransport(url, opts);
|
||||||
|
await wt.ready;
|
||||||
|
transport = wt;
|
||||||
|
writer = wt.datagrams.writable.getWriter();
|
||||||
|
setStatus('connected to server');
|
||||||
|
console.log('WebTransport connected successfully');
|
||||||
|
|
||||||
|
readLoop();
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
|
||||||
|
// Send JOIN immediately (spectator → player upon space handled below)
|
||||||
|
const pkt = buildJoin(1, nextSeq(), ui.name.value.trim());
|
||||||
|
await writer.write(pkt);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WebTransport connection failed:', err);
|
||||||
|
if (err.message.includes('certificate')) {
|
||||||
|
setStatus('ERROR: Certificate validation failed. Check cert hash or use valid CA cert.');
|
||||||
|
} else if (err.message.includes('net::')) {
|
||||||
|
setStatus('ERROR: Network error - is server running on ' + url + '?');
|
||||||
|
} else {
|
||||||
|
setStatus('ERROR: ' + err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const wt = new WebTransport(url, opts);
|
|
||||||
await wt.ready;
|
|
||||||
transport = wt;
|
|
||||||
writer = wt.datagrams.writable.getWriter();
|
|
||||||
setStatus('connected');
|
|
||||||
readLoop();
|
|
||||||
requestAnimationFrame(render);
|
|
||||||
// Send JOIN immediately (spectator → player upon space handled below)
|
|
||||||
const pkt = buildJoin(1, nextSeq(), ui.name.value.trim());
|
|
||||||
await writer.write(pkt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dirFromKey(e) {
|
function dirFromKey(e) {
|
||||||
@@ -157,7 +266,47 @@ async function onKey(e) {
|
|||||||
try { await writer.write(pkt); } catch (err) { /* ignore */ }
|
try { await writer.write(pkt); } catch (err) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-configure on page load
|
||||||
|
async function autoConfigureFromServer() {
|
||||||
|
try {
|
||||||
|
// Try to fetch cert hash from the static server
|
||||||
|
const response = await fetch('/cert-hash.json');
|
||||||
|
if (!response.ok) {
|
||||||
|
setStatus('Auto-config unavailable, please enter manually');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const config = await response.json();
|
||||||
|
|
||||||
|
// Auto-populate fields
|
||||||
|
if (config.wtUrl) {
|
||||||
|
// Use the hostname from browser but with the WT port from server
|
||||||
|
const hostname = window.location.hostname || '127.0.0.1';
|
||||||
|
const wtPort = config.wtPort || 4433;
|
||||||
|
ui.url.value = `https://${hostname}:${wtPort}/`;
|
||||||
|
}
|
||||||
|
if (config.sha256) {
|
||||||
|
ui.hash.value = config.sha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Auto-configured from server');
|
||||||
|
console.log('Auto-configured:', config);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Auto-config failed:', err);
|
||||||
|
// Fallback: just populate URL from browser location
|
||||||
|
const hostname = window.location.hostname || '127.0.0.1';
|
||||||
|
ui.url.value = `https://${hostname}:4433/`;
|
||||||
|
setStatus('Manual configuration required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ui.connect.onclick = () => { connectWT().catch(e => setStatus('connect failed: ' + e)); };
|
ui.connect.onclick = () => { connectWT().catch(e => setStatus('connect failed: ' + e)); };
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
window.addEventListener('resize', render);
|
window.addEventListener('resize', render);
|
||||||
|
|
||||||
|
// Auto-configure when page loads
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', autoConfigureFromServer);
|
||||||
|
} else {
|
||||||
|
autoConfigureFromServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,15 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="ui">
|
<div id="ui">
|
||||||
<label>Server URL (WebTransport): <input id="url" value="https://localhost:4433/"/></label>
|
<label>Server URL (WebTransport): <input id="url" value="https://localhost:4433/" placeholder="Auto-configured..."/></label>
|
||||||
<label>SHA-256 Cert Hash (hex, optional for self-signed): <input id="hash" placeholder="e.g., aabbcc..."/></label>
|
<label>Certificate Hash (SHA-256, auto-configured): <input id="hash" placeholder="Auto-configured from server..."/></label>
|
||||||
<label>Name: <input id="name" value="guest"/></label>
|
<label>Name: <input id="name" value="guest"/></label>
|
||||||
<button id="connect">Connect</button>
|
<button id="connect">Connect</button>
|
||||||
<span id="status"></span>
|
<span id="status"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="overlay">press space to join</div>
|
<div id="overlay">press space to join</div>
|
||||||
<canvas id="view"></canvas>
|
<canvas id="view"></canvas>
|
||||||
<script src="protocol.js"></script>
|
<script type="module" src="client.js"></script>
|
||||||
<script src="client.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -158,3 +158,57 @@ export function parseStateFullBody(dv, off) {
|
|||||||
return { snakes, apples, off };
|
return { snakes, apples, off };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseStateDeltaBody(dv, off) {
|
||||||
|
// Parse STATE_DELTA body format (mirrors server/protocol.py build_state_delta_body)
|
||||||
|
// update_id (u16)
|
||||||
|
const updateId = dv.getUint16(off); off += 2;
|
||||||
|
|
||||||
|
// changes count (QUIC varint)
|
||||||
|
let [changesCount, p1] = quicVarintDecode(dv, off); off = p1;
|
||||||
|
|
||||||
|
const changes = [];
|
||||||
|
for (let i = 0; i < changesCount; i++) {
|
||||||
|
const snakeId = dv.getUint8(off); off += 1;
|
||||||
|
const flags = dv.getUint8(off); off += 1;
|
||||||
|
const direction = dv.getUint8(off) & 0x03; off += 1;
|
||||||
|
|
||||||
|
const headMoved = (flags & 0x01) !== 0;
|
||||||
|
const tailRemoved = (flags & 0x02) !== 0;
|
||||||
|
const grew = (flags & 0x04) !== 0;
|
||||||
|
const blocked = (flags & 0x08) !== 0;
|
||||||
|
|
||||||
|
let newHeadX = 0, newHeadY = 0;
|
||||||
|
if (headMoved) {
|
||||||
|
newHeadX = dv.getUint8(off); off += 1;
|
||||||
|
newHeadY = dv.getUint8(off); off += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.push({
|
||||||
|
snakeId,
|
||||||
|
headMoved,
|
||||||
|
tailRemoved,
|
||||||
|
grew,
|
||||||
|
blocked,
|
||||||
|
direction,
|
||||||
|
newHeadX,
|
||||||
|
newHeadY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// apples added (count + coords)
|
||||||
|
let [applesAddedCount, p2] = quicVarintDecode(dv, off); off = p2;
|
||||||
|
const applesAdded = [];
|
||||||
|
for (let i = 0; i < applesAddedCount; i++) {
|
||||||
|
applesAdded.push([dv.getUint8(off++), dv.getUint8(off++)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// apples removed (count + coords)
|
||||||
|
let [applesRemovedCount, p3] = quicVarintDecode(dv, off); off = p3;
|
||||||
|
const applesRemoved = [];
|
||||||
|
for (let i = 0; i < applesRemovedCount; i++) {
|
||||||
|
applesRemoved.push([dv.getUint8(off++), dv.getUint8(off++)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updateId, changes, applesAdded, applesRemoved, off };
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
369
run.py
369
run.py
@@ -1,5 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -7,109 +7,332 @@ from server.server import GameServer
|
|||||||
from server.config import ServerConfig
|
from server.config import ServerConfig
|
||||||
|
|
||||||
|
|
||||||
async def _run_tasks_with_optional_timeout(tasks):
|
async def _run_tasks_with_optional_timeout(tasks, timeout_s=None):
|
||||||
"""Await tasks, optionally honoring RUN_SECONDS env var to cancel after a timeout."""
|
"""Await tasks, optionally with a timeout to cancel after specified seconds."""
|
||||||
timeout_s = os.environ.get("RUN_SECONDS")
|
|
||||||
if not timeout_s:
|
if not timeout_s:
|
||||||
await asyncio.gather(*tasks)
|
await asyncio.gather(*tasks)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(asyncio.gather(*tasks), timeout=float(timeout_s))
|
await asyncio.wait_for(asyncio.gather(*tasks), timeout=float(timeout_s))
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logging.info("Timeout reached (RUN_SECONDS=%s); stopping server tasks...", timeout_s)
|
logging.info("Timeout reached (%s seconds); stopping server tasks...", timeout_s)
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
t.cancel()
|
t.cancel()
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
async def run_in_memory():
|
async def run_in_memory(args):
|
||||||
from server.transport import InMemoryTransport
|
from server.transport import InMemoryTransport
|
||||||
cfg = ServerConfig()
|
cfg = ServerConfig(
|
||||||
|
width=args.width,
|
||||||
|
height=args.height,
|
||||||
|
tick_rate=args.tick_rate,
|
||||||
|
wrap_edges=args.wrap_edges,
|
||||||
|
apples_per_snake=args.apples_per_snake,
|
||||||
|
apples_cap=args.apples_cap,
|
||||||
|
players_max=args.players_max,
|
||||||
|
)
|
||||||
server = GameServer(transport=InMemoryTransport(lambda d, p: server.on_datagram(d, p)), config=cfg)
|
server = GameServer(transport=InMemoryTransport(lambda d, p: server.on_datagram(d, p)), config=cfg)
|
||||||
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
|
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
|
||||||
await _run_tasks_with_optional_timeout(tasks)
|
await _run_tasks_with_optional_timeout(tasks, args.run_seconds)
|
||||||
|
|
||||||
|
|
||||||
async def run_quic():
|
async def run_quic(args):
|
||||||
|
import os
|
||||||
from server.quic_transport import QuicWebTransportServer
|
from server.quic_transport import QuicWebTransportServer
|
||||||
cfg = ServerConfig()
|
from server.utils import combine_cert_chain
|
||||||
host = os.environ.get("QUIC_HOST", "0.0.0.0")
|
|
||||||
port = int(os.environ.get("QUIC_PORT", "4433"))
|
|
||||||
cert = os.environ["QUIC_CERT"]
|
|
||||||
key = os.environ["QUIC_KEY"]
|
|
||||||
server = GameServer(transport=QuicWebTransportServer(host, port, cert, key, lambda d, p: server.on_datagram(d, p)), config=cfg)
|
|
||||||
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
|
|
||||||
await _run_tasks_with_optional_timeout(tasks)
|
|
||||||
|
|
||||||
|
cfg = ServerConfig(
|
||||||
|
width=args.width,
|
||||||
|
height=args.height,
|
||||||
|
tick_rate=args.tick_rate,
|
||||||
|
wrap_edges=args.wrap_edges,
|
||||||
|
apples_per_snake=args.apples_per_snake,
|
||||||
|
apples_cap=args.apples_cap,
|
||||||
|
players_max=args.players_max,
|
||||||
|
)
|
||||||
|
|
||||||
async def run_webtransport():
|
# Handle certificate chain
|
||||||
from server.webtransport_server import WebTransportServer
|
# Check if cert file contains multiple certificates (like Let's Encrypt fullchain.pem)
|
||||||
from server.static_server import start_https_static
|
with open(args.cert, 'r') as f:
|
||||||
cfg = ServerConfig()
|
cert_content = f.read()
|
||||||
host = os.environ.get("WT_HOST", os.environ.get("QUIC_HOST", "0.0.0.0"))
|
|
||||||
port = int(os.environ.get("WT_PORT", os.environ.get("QUIC_PORT", "4433")))
|
from server.utils import split_pem_certificates
|
||||||
cert = os.environ.get("WT_CERT") or os.environ["QUIC_CERT"]
|
certs_in_file = split_pem_certificates(cert_content)
|
||||||
key = os.environ.get("WT_KEY") or os.environ["QUIC_KEY"]
|
|
||||||
# Optional static HTTPS server for client assets
|
cert_file = args.cert
|
||||||
static = os.environ.get("STATIC", "1")
|
temp_cert_file = None
|
||||||
static_host = os.environ.get("STATIC_HOST", host)
|
|
||||||
static_port = int(os.environ.get("STATIC_PORT", "8443"))
|
if args.cert_chain:
|
||||||
static_root = os.environ.get("STATIC_ROOT", "client")
|
# Combine cert + chain files into single file
|
||||||
httpd = None
|
temp_cert_file = combine_cert_chain(args.cert, args.cert_chain)
|
||||||
if static == "1":
|
cert_file = temp_cert_file
|
||||||
httpd, _t = start_https_static(static_host, static_port, cert, key, static_root)
|
logging.info("Combined certificate with %d chain file(s)", len(args.cert_chain))
|
||||||
print(f"HTTPS static server: https://{static_host}:{static_port}/ serving '{static_root}'")
|
elif len(certs_in_file) > 1:
|
||||||
server = GameServer(transport=WebTransportServer(host, port, cert, key, lambda d, p: server.on_datagram(d, p)), config=cfg)
|
# Certificate file contains full chain - reformat it for aioquic
|
||||||
print(f"WebTransport server: https://{host}:{port}/ (HTTP/3)")
|
temp_cert_file = combine_cert_chain(args.cert, [])
|
||||||
|
cert_file = temp_cert_file
|
||||||
|
logging.info("Reformatted certificate chain from fullchain file (%d certs)", len(certs_in_file))
|
||||||
|
|
||||||
|
server = GameServer(transport=QuicWebTransportServer(args.quic_host, args.quic_port, cert_file, args.key, lambda d, p: server.on_datagram(d, p)), config=cfg)
|
||||||
|
logging.info("QUIC server: %s:%d", args.quic_host, args.quic_port)
|
||||||
try:
|
try:
|
||||||
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
|
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
|
||||||
await _run_tasks_with_optional_timeout(tasks)
|
await _run_tasks_with_optional_timeout(tasks, args.run_seconds)
|
||||||
|
finally:
|
||||||
|
if temp_cert_file:
|
||||||
|
os.unlink(temp_cert_file) # Clean up temp combined cert file
|
||||||
|
|
||||||
|
|
||||||
|
async def run_webtransport(args):
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from server.webtransport_server import WebTransportServer
|
||||||
|
from server.static_server import start_https_static
|
||||||
|
from server.utils import get_cert_sha256_hash, combine_cert_chain, extract_server_cert
|
||||||
|
|
||||||
|
cfg = ServerConfig(
|
||||||
|
width=args.width,
|
||||||
|
height=args.height,
|
||||||
|
tick_rate=args.tick_rate,
|
||||||
|
wrap_edges=args.wrap_edges,
|
||||||
|
apples_per_snake=args.apples_per_snake,
|
||||||
|
apples_cap=args.apples_cap,
|
||||||
|
players_max=args.players_max,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle certificate chain
|
||||||
|
# Check if cert file contains multiple certificates (like Let's Encrypt fullchain.pem)
|
||||||
|
with open(args.cert, 'r') as f:
|
||||||
|
cert_content = f.read()
|
||||||
|
|
||||||
|
from server.utils import split_pem_certificates
|
||||||
|
certs_in_file = split_pem_certificates(cert_content)
|
||||||
|
|
||||||
|
cert_file = args.cert
|
||||||
|
temp_cert_file = None
|
||||||
|
|
||||||
|
if args.cert_chain:
|
||||||
|
# Combine cert + chain files into single file
|
||||||
|
temp_cert_file = combine_cert_chain(args.cert, args.cert_chain)
|
||||||
|
cert_file = temp_cert_file
|
||||||
|
logging.info("Combined certificate with %d chain file(s)", len(args.cert_chain))
|
||||||
|
elif len(certs_in_file) > 1:
|
||||||
|
# Certificate file contains full chain - reformat it for aioquic
|
||||||
|
temp_cert_file = combine_cert_chain(args.cert, [])
|
||||||
|
cert_file = temp_cert_file
|
||||||
|
logging.info("Reformatted certificate chain from fullchain file (%d certs)", len(certs_in_file))
|
||||||
|
|
||||||
|
# Calculate certificate hash for WebTransport client (only hash server cert, not chain)
|
||||||
|
server_cert_file = extract_server_cert(args.cert)
|
||||||
|
cert_hash = get_cert_sha256_hash(server_cert_file)
|
||||||
|
if server_cert_file != args.cert:
|
||||||
|
os.unlink(server_cert_file) # Clean up temp file
|
||||||
|
|
||||||
|
# Prepare cert-hash.json content
|
||||||
|
cert_hash_json = json.dumps({
|
||||||
|
"sha256": cert_hash,
|
||||||
|
"wtUrl": f"https://{args.wt_host}:{args.wt_port}/",
|
||||||
|
"wtPort": args.wt_port
|
||||||
|
})
|
||||||
|
|
||||||
|
# Optional static HTTPS server for client assets
|
||||||
|
httpd = None
|
||||||
|
if args.static:
|
||||||
|
httpd, _t = start_https_static(
|
||||||
|
args.static_host,
|
||||||
|
args.static_port,
|
||||||
|
cert_file,
|
||||||
|
args.key,
|
||||||
|
args.static_root,
|
||||||
|
cert_hash_json=cert_hash_json
|
||||||
|
)
|
||||||
|
print(f"HTTPS static server: https://{args.static_host}:{args.static_port}/ serving '{args.static_root}'")
|
||||||
|
print(f"Certificate SHA-256: {cert_hash}")
|
||||||
|
|
||||||
|
server = GameServer(transport=WebTransportServer(args.wt_host, args.wt_port, cert_file, args.key, lambda d, p: server.on_datagram(d, p)), config=cfg)
|
||||||
|
print(f"WebTransport server: https://{args.wt_host}:{args.wt_port}/ (HTTP/3)")
|
||||||
|
try:
|
||||||
|
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
|
||||||
|
await _run_tasks_with_optional_timeout(tasks, args.run_seconds)
|
||||||
finally:
|
finally:
|
||||||
if httpd is not None:
|
if httpd is not None:
|
||||||
httpd.shutdown()
|
httpd.shutdown()
|
||||||
|
if temp_cert_file:
|
||||||
|
os.unlink(temp_cert_file) # Clean up temp combined cert file
|
||||||
|
|
||||||
|
|
||||||
|
async def run_net(args):
|
||||||
|
import os
|
||||||
|
from server.webtransport_server import WebTransportServer
|
||||||
|
from server.quic_transport import QuicWebTransportServer
|
||||||
|
from server.multi_transport import MultiTransport
|
||||||
|
from server.utils import combine_cert_chain
|
||||||
|
|
||||||
|
cfg = ServerConfig(
|
||||||
|
width=args.width,
|
||||||
|
height=args.height,
|
||||||
|
tick_rate=args.tick_rate,
|
||||||
|
wrap_edges=args.wrap_edges,
|
||||||
|
apples_per_snake=args.apples_per_snake,
|
||||||
|
apples_cap=args.apples_cap,
|
||||||
|
players_max=args.players_max,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle certificate chain
|
||||||
|
# Check if cert file contains multiple certificates (like Let's Encrypt fullchain.pem)
|
||||||
|
with open(args.cert, 'r') as f:
|
||||||
|
cert_content = f.read()
|
||||||
|
|
||||||
|
from server.utils import split_pem_certificates
|
||||||
|
certs_in_file = split_pem_certificates(cert_content)
|
||||||
|
|
||||||
|
cert_file = args.cert
|
||||||
|
temp_cert_file = None
|
||||||
|
|
||||||
|
if args.cert_chain:
|
||||||
|
# Combine cert + chain files into single file
|
||||||
|
temp_cert_file = combine_cert_chain(args.cert, args.cert_chain)
|
||||||
|
cert_file = temp_cert_file
|
||||||
|
logging.info("Combined certificate with %d chain file(s)", len(args.cert_chain))
|
||||||
|
elif len(certs_in_file) > 1:
|
||||||
|
# Certificate file contains full chain - reformat it for aioquic
|
||||||
|
temp_cert_file = combine_cert_chain(args.cert, [])
|
||||||
|
cert_file = temp_cert_file
|
||||||
|
logging.info("Reformatted certificate chain from fullchain file (%d certs)", len(certs_in_file))
|
||||||
|
|
||||||
|
server: GameServer
|
||||||
|
wt = WebTransportServer(args.wt_host, args.wt_port, cert_file, args.key, lambda d, p: server.on_datagram(d, p))
|
||||||
|
qu = QuicWebTransportServer(args.quic_host, args.quic_port, cert_file, args.key, lambda d, p: server.on_datagram(d, p))
|
||||||
|
m = MultiTransport(wt, qu)
|
||||||
|
server = GameServer(transport=m, config=cfg)
|
||||||
|
logging.info("WebTransport server: %s:%d", args.wt_host, args.wt_port)
|
||||||
|
logging.info("QUIC server: %s:%d", args.quic_host, args.quic_port)
|
||||||
|
try:
|
||||||
|
tasks = [asyncio.create_task(m.run()), asyncio.create_task(server.tick_loop())]
|
||||||
|
await _run_tasks_with_optional_timeout(tasks, args.run_seconds)
|
||||||
|
finally:
|
||||||
|
if temp_cert_file:
|
||||||
|
os.unlink(temp_cert_file) # Clean up temp combined cert file
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Real-time multiplayer Snake game server",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
python run.py # WebTransport mode with defaults
|
||||||
|
python run.py --mode mem # In-memory mode (testing)
|
||||||
|
python run.py --mode quic --quic-port 5000 # QUIC mode on custom port
|
||||||
|
python run.py --cert my.pem --key my.key # Custom TLS certificate
|
||||||
|
python run.py --no-static # Disable static HTTPS server
|
||||||
|
python run.py --width 80 --height 60 # Custom field size
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Transport mode
|
||||||
|
parser.add_argument("--mode", choices=["mem", "wt", "quic", "net"], default="wt",
|
||||||
|
help="Transport mode: mem (in-memory), wt (WebTransport/HTTP3), quic (QUIC datagrams), net (both wt+quic). Default: wt")
|
||||||
|
|
||||||
|
# TLS certificate/key
|
||||||
|
parser.add_argument("--cert", default="cert.pem",
|
||||||
|
help="TLS certificate file path (may contain full chain). Default: cert.pem")
|
||||||
|
parser.add_argument("--key", default="key.pem",
|
||||||
|
help="TLS private key file path. Default: key.pem")
|
||||||
|
parser.add_argument("--cert-chain", action="append", dest="cert_chain",
|
||||||
|
help="Intermediate certificate file (can be used multiple times for long chains)")
|
||||||
|
|
||||||
|
# WebTransport server settings
|
||||||
|
parser.add_argument("--wt-host", default="0.0.0.0",
|
||||||
|
help="WebTransport server host. Default: 0.0.0.0")
|
||||||
|
parser.add_argument("--wt-port", type=int, default=4433,
|
||||||
|
help="WebTransport server port. Default: 4433")
|
||||||
|
|
||||||
|
# QUIC server settings
|
||||||
|
parser.add_argument("--quic-host", default="0.0.0.0",
|
||||||
|
help="QUIC server host. Default: 0.0.0.0")
|
||||||
|
parser.add_argument("--quic-port", type=int, default=4433,
|
||||||
|
help="QUIC server port. Default: 4433 (or 4443 in net mode)")
|
||||||
|
|
||||||
|
# Static HTTPS server settings
|
||||||
|
parser.add_argument("--static", dest="static", action="store_true", default=True,
|
||||||
|
help="Enable static HTTPS server for client files (default in wt mode)")
|
||||||
|
parser.add_argument("--no-static", dest="static", action="store_false",
|
||||||
|
help="Disable static HTTPS server")
|
||||||
|
parser.add_argument("--static-host", default=None,
|
||||||
|
help="Static server host. Default: same as wt-host")
|
||||||
|
parser.add_argument("--static-port", type=int, default=8443,
|
||||||
|
help="Static server port. Default: 8443")
|
||||||
|
parser.add_argument("--static-root", default="client",
|
||||||
|
help="Static files directory. Default: client")
|
||||||
|
|
||||||
|
# Game configuration
|
||||||
|
parser.add_argument("--width", type=int, default=60,
|
||||||
|
help="Field width (3-255). Default: 60")
|
||||||
|
parser.add_argument("--height", type=int, default=40,
|
||||||
|
help="Field height (3-255). Default: 40")
|
||||||
|
parser.add_argument("--tick-rate", type=int, default=10,
|
||||||
|
help="Server tick rate in TPS (5-30). Default: 10")
|
||||||
|
parser.add_argument("--wrap-edges", action="store_true", default=False,
|
||||||
|
help="Enable edge wrapping (default: disabled)")
|
||||||
|
parser.add_argument("--apples-per-snake", type=int, default=1,
|
||||||
|
help="Apples per connected snake (1-12). Default: 1")
|
||||||
|
parser.add_argument("--apples-cap", type=int, default=255,
|
||||||
|
help="Maximum total apples (0-255). Default: 255")
|
||||||
|
parser.add_argument("--players-max", type=int, default=32,
|
||||||
|
help="Maximum concurrent players. Default: 32")
|
||||||
|
|
||||||
|
# Logging and testing
|
||||||
|
parser.add_argument("--log-level", default="INFO",
|
||||||
|
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||||
|
help="Logging level. Default: INFO")
|
||||||
|
parser.add_argument("--run-seconds", type=float, default=None,
|
||||||
|
help="Optional timeout in seconds for testing")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Post-process: static-host defaults to wt-host
|
||||||
|
if args.static_host is None:
|
||||||
|
args.static_host = args.wt_host
|
||||||
|
|
||||||
|
# In net mode, default quic-port to 4443 if not explicitly set
|
||||||
|
if args.mode == "net" and "--quic-port" not in sys.argv:
|
||||||
|
args.quic_port = 4443
|
||||||
|
|
||||||
|
# Validate TLS files for modes that need them
|
||||||
|
if args.mode in ("wt", "quic", "net"):
|
||||||
|
import os
|
||||||
|
if not os.path.exists(args.cert):
|
||||||
|
parser.error(f"Certificate file not found: {args.cert}")
|
||||||
|
if not os.path.exists(args.key):
|
||||||
|
parser.error(f"Key file not found: {args.key}")
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
if any(a in ("-h", "--help") for a in sys.argv[1:]):
|
args = parse_args()
|
||||||
print(
|
|
||||||
"Usage: python run.py [--mode mem|quic|wt] [--log-level LEVEL] [--run-seconds N]\n"
|
|
||||||
" TLS (for wt/quic): set QUIC_CERT/QUIC_KEY or WT_CERT/WT_KEY env vars\n"
|
|
||||||
" WT static server (MODE=wt): STATIC=1 [STATIC_HOST/PORT/ROOT]\n"
|
|
||||||
"Examples:\n MODE=wt QUIC_CERT=cert.pem QUIC_KEY=key.pem python run.py\n MODE=mem python run.py"
|
|
||||||
)
|
|
||||||
sys.exit(0)
|
|
||||||
# Logging setup
|
# Logging setup
|
||||||
level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
logging.basicConfig(
|
||||||
logging.basicConfig(level=getattr(logging, level, logging.INFO), format="[%(asctime)s] %(levelname)s: %(message)s")
|
level=getattr(logging, args.log_level.upper()),
|
||||||
mode = os.environ.get("MODE", "mem").lower()
|
format="[%(asctime)s] %(levelname)s: %(message)s"
|
||||||
if mode == "wt":
|
)
|
||||||
|
|
||||||
|
# Run appropriate mode
|
||||||
|
if args.mode == "wt":
|
||||||
logging.info("Starting in WebTransport mode")
|
logging.info("Starting in WebTransport mode")
|
||||||
asyncio.run(run_webtransport())
|
asyncio.run(run_webtransport(args))
|
||||||
elif mode == "quic":
|
elif args.mode == "quic":
|
||||||
logging.info("Starting in QUIC datagram mode")
|
logging.info("Starting in QUIC datagram mode")
|
||||||
asyncio.run(run_quic())
|
asyncio.run(run_quic(args))
|
||||||
else:
|
elif args.mode == "net":
|
||||||
|
logging.info("Starting in combined WebTransport+QUIC mode")
|
||||||
|
asyncio.run(run_net(args))
|
||||||
|
else: # mem
|
||||||
logging.info("Starting in in-memory transport mode")
|
logging.info("Starting in in-memory transport mode")
|
||||||
asyncio.run(run_in_memory())
|
asyncio.run(run_in_memory(args))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_tasks_with_optional_timeout(tasks):
|
|
||||||
"""Await tasks, optionally honoring RUN_SECONDS env var to cancel after a timeout."""
|
|
||||||
timeout_s = os.environ.get("RUN_SECONDS")
|
|
||||||
if not timeout_s:
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(asyncio.gather(*tasks), timeout=float(timeout_s))
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logging.info("Timeout reached (RUN_SECONDS=%s); stopping server tasks...", timeout_s)
|
|
||||||
for t in tasks:
|
|
||||||
t.cancel()
|
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
|
|||||||
26
server/multi_transport.py
Normal file
26
server/multi_transport.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from .transport import DatagramServerTransport, TransportPeer
|
||||||
|
|
||||||
|
|
||||||
|
class MultiTransport(DatagramServerTransport):
|
||||||
|
"""Run both WebTransport and QUIC transports and route sends.
|
||||||
|
|
||||||
|
Inbound datagrams share the same on_datagram callback from GameServer,
|
||||||
|
so GameServer sees a unified source of datagrams.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, wt_transport: DatagramServerTransport, quic_transport: DatagramServerTransport):
|
||||||
|
self._wt = wt_transport
|
||||||
|
self._quic = quic_transport
|
||||||
|
|
||||||
|
async def send(self, data: bytes, peer: TransportPeer) -> None:
|
||||||
|
# WebTransport peers are tuples (proto, flow_id); QUIC uses protocol instance
|
||||||
|
if isinstance(peer.addr, tuple) and len(peer.addr) == 2:
|
||||||
|
await self._wt.send(data, peer)
|
||||||
|
else:
|
||||||
|
await self._quic.send(data, peer)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
await asyncio.gather(self._wt.run(), self._quic.run())
|
||||||
@@ -44,7 +44,7 @@ class GameQuicProtocol(QuicConnectionProtocol): # type: ignore[misc]
|
|||||||
async def send_datagram(self, data: bytes) -> None:
|
async def send_datagram(self, data: bytes) -> None:
|
||||||
logging.debug("QUIC send datagram: %d bytes", len(data))
|
logging.debug("QUIC send datagram: %d bytes", len(data))
|
||||||
self._quic.send_datagram_frame(data) # type: ignore[attr-defined]
|
self._quic.send_datagram_frame(data) # type: ignore[attr-defined]
|
||||||
await self._loop.run_in_executor(None, self.transmit) # type: ignore[attr-defined]
|
self.transmit() # Send queued QUIC packets immediately
|
||||||
|
|
||||||
|
|
||||||
class QuicWebTransportServer(DatagramServerTransport):
|
class QuicWebTransportServer(DatagramServerTransport):
|
||||||
@@ -65,15 +65,20 @@ class QuicWebTransportServer(DatagramServerTransport):
|
|||||||
await proto.send_datagram(data)
|
await proto.send_datagram(data)
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
configuration = QuicConfiguration(is_client=False, alpn_protocols=["h3"])
|
configuration = QuicConfiguration(
|
||||||
|
is_client=False,
|
||||||
|
alpn_protocols=["h3"],
|
||||||
|
max_datagram_frame_size=65536 # Enable QUIC datagrams
|
||||||
|
)
|
||||||
configuration.load_cert_chain(self.certfile, self.keyfile)
|
configuration.load_cert_chain(self.certfile, self.keyfile)
|
||||||
|
|
||||||
async def _create_protocol(*args, **kwargs):
|
def _create_protocol(*args, **kwargs):
|
||||||
return GameQuicProtocol(*args, on_datagram=self._on_datagram, peers=self._peers, **kwargs)
|
return GameQuicProtocol(*args, on_datagram=self._on_datagram, peers=self._peers, **kwargs)
|
||||||
|
|
||||||
logging.info("QUIC datagram server listening on %s:%d", self.host, self.port)
|
logging.info("QUIC datagram server listening on %s:%d", self.host, self.port)
|
||||||
self._server = await serve(self.host, self.port, configuration=configuration, create_protocol=_create_protocol)
|
self._server = await serve(self.host, self.port, configuration=configuration, create_protocol=_create_protocol)
|
||||||
try:
|
try:
|
||||||
await self._server.wait_closed()
|
# Wait indefinitely until cancelled (e.g., by KeyboardInterrupt or timeout)
|
||||||
|
await asyncio.Event().wait()
|
||||||
finally:
|
finally:
|
||||||
self._server.close()
|
self._server.close()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -136,7 +136,7 @@ class GameServer:
|
|||||||
# --- Simulation ---
|
# --- Simulation ---
|
||||||
|
|
||||||
def _consume_input_for_snake(self, s: Snake) -> None:
|
def _consume_input_for_snake(self, s: Snake) -> None:
|
||||||
# Consume at most one input; skip 180° turns when length>1
|
# Consume at most one input; skip 180° turns when length>1
|
||||||
while s.input_buf:
|
while s.input_buf:
|
||||||
nd = s.input_buf[0]
|
nd = s.input_buf[0]
|
||||||
# 180-degree check
|
# 180-degree check
|
||||||
@@ -452,7 +452,7 @@ class GameServer:
|
|||||||
full = pack_header(
|
full = pack_header(
|
||||||
self.runtime.version, PacketType.STATE_FULL, 0, self.runtime.next_seq(), self.runtime.state.tick & 0xFFFF
|
self.runtime.version, PacketType.STATE_FULL, 0, self.runtime.next_seq(), self.runtime.state.tick & 0xFFFF
|
||||||
) + body
|
) + body
|
||||||
await self.transport.send(full, peer)
|
await self.transport.send(full, peer)
|
||||||
else:
|
else:
|
||||||
# Partition by packing whole snakes first, apples only in first part; chunk a single oversized snake using 2-bit chunks
|
# Partition by packing whole snakes first, apples only in first part; chunk a single oversized snake using 2-bit chunks
|
||||||
update_id = self.runtime.next_update_id()
|
update_id = self.runtime.next_update_id()
|
||||||
@@ -507,7 +507,7 @@ class GameServer:
|
|||||||
dir_capacity_bytes = max(0, budget - overhead)
|
dir_capacity_bytes = max(0, budget - overhead)
|
||||||
if dir_capacity_bytes <= 0:
|
if dir_capacity_bytes <= 0:
|
||||||
break
|
break
|
||||||
# Convert capacity bytes to number of directions (each 2 bits → 4 dirs per byte)
|
# Convert capacity bytes to number of directions (each 2 bits → 4 dirs per byte)
|
||||||
dir_capacity = dir_capacity_bytes * 4
|
dir_capacity = dir_capacity_bytes * 4
|
||||||
if dir_capacity <= 0:
|
if dir_capacity <= 0:
|
||||||
break
|
break
|
||||||
@@ -571,6 +571,33 @@ class GameServer:
|
|||||||
if player_id is None:
|
if player_id is None:
|
||||||
return
|
return
|
||||||
logging.debug("INPUT from player_id=%d: %d events (base_tick=%d)", player_id, len(events), base_tick)
|
logging.debug("INPUT from player_id=%d: %d events (base_tick=%d)", player_id, len(events), base_tick)
|
||||||
|
|
||||||
|
# Apply inputs to snake buffer (client-side filtering already applied)
|
||||||
|
snake = self.runtime.state.snakes.get(player_id)
|
||||||
|
if snake is not None:
|
||||||
|
for ev in events:
|
||||||
|
# Apply input buffer rules from project plan:
|
||||||
|
# - Max capacity 3
|
||||||
|
# - If opposite to last buffered, replace last
|
||||||
|
# - Drop duplicates
|
||||||
|
# - Overflow: replace last
|
||||||
|
if len(snake.input_buf) > 0:
|
||||||
|
last_dir = snake.input_buf[-1]
|
||||||
|
# Check if opposite (XOR == 2)
|
||||||
|
if (int(ev.direction) ^ int(last_dir)) == 2:
|
||||||
|
# Replace last
|
||||||
|
snake.input_buf[-1] = ev.direction
|
||||||
|
continue
|
||||||
|
# Check if duplicate
|
||||||
|
if ev.direction == last_dir:
|
||||||
|
continue
|
||||||
|
# Add to buffer
|
||||||
|
if len(snake.input_buf) < 3:
|
||||||
|
snake.input_buf.append(ev.direction)
|
||||||
|
else:
|
||||||
|
# Overflow: replace last
|
||||||
|
snake.input_buf[-1] = ev.direction
|
||||||
|
|
||||||
# Relay to others immediately for prediction
|
# Relay to others immediately for prediction
|
||||||
await self.relay_input_broadcast(
|
await self.relay_input_broadcast(
|
||||||
from_player_id=player_id,
|
from_player_id=player_id,
|
||||||
|
|||||||
@@ -3,25 +3,66 @@ from __future__ import annotations
|
|||||||
import ssl
|
import ssl
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import json
|
||||||
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Tuple
|
from typing import Tuple, Optional
|
||||||
|
|
||||||
|
|
||||||
class _Handler(SimpleHTTPRequestHandler):
|
class _Handler(SimpleHTTPRequestHandler):
|
||||||
# Allow passing a base directory at construction time
|
# Allow passing a base directory and cert_hash_json at construction time
|
||||||
|
cert_hash_json: Optional[str] = None
|
||||||
|
|
||||||
def __init__(self, *args, directory: str | None = None, **kwargs):
|
def __init__(self, *args, directory: str | None = None, **kwargs):
|
||||||
super().__init__(*args, directory=directory, **kwargs)
|
super().__init__(*args, directory=directory, **kwargs)
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
# Add no-cache headers for all static files to force browser to fetch fresh versions
|
||||||
|
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||||
|
self.send_header('Pragma', 'no-cache')
|
||||||
|
self.send_header('Expires', '0')
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
def start_https_static(host: str, port: int, certfile: str, keyfile: str, docroot: str) -> Tuple[ThreadingHTTPServer, threading.Thread]:
|
def do_GET(self):
|
||||||
|
# Intercept /cert-hash.json requests
|
||||||
|
if self.path == '/cert-hash.json' and self.cert_hash_json:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(self.cert_hash_json.encode('utf-8'))
|
||||||
|
else:
|
||||||
|
# Serve regular static files
|
||||||
|
super().do_GET()
|
||||||
|
|
||||||
|
|
||||||
|
def start_https_static(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
certfile: str,
|
||||||
|
keyfile: str,
|
||||||
|
docroot: str,
|
||||||
|
cert_hash_json: Optional[str] = None
|
||||||
|
) -> Tuple[ThreadingHTTPServer, threading.Thread]:
|
||||||
"""Start a simple HTTPS static file server in a background thread.
|
"""Start a simple HTTPS static file server in a background thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Host to bind to
|
||||||
|
port: Port to bind to
|
||||||
|
certfile: Path to TLS certificate
|
||||||
|
keyfile: Path to TLS private key
|
||||||
|
docroot: Document root directory
|
||||||
|
cert_hash_json: Optional JSON string to serve at /cert-hash.json
|
||||||
|
|
||||||
Returns the (httpd, thread). Caller is responsible for calling httpd.shutdown()
|
Returns the (httpd, thread). Caller is responsible for calling httpd.shutdown()
|
||||||
to stop the server on application exit.
|
to stop the server on application exit.
|
||||||
"""
|
"""
|
||||||
docroot_path = str(Path(docroot).resolve())
|
docroot_path = str(Path(docroot).resolve())
|
||||||
|
|
||||||
|
# Set class variable for the handler
|
||||||
|
if cert_hash_json:
|
||||||
|
_Handler.cert_hash_json = cert_hash_json
|
||||||
|
|
||||||
def handler(*args, **kwargs):
|
def handler(*args, **kwargs):
|
||||||
return _Handler(*args, directory=docroot_path, **kwargs)
|
return _Handler(*args, directory=docroot_path, **kwargs)
|
||||||
|
|
||||||
|
|||||||
132
server/utils.py
132
server/utils.py
@@ -1,5 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from typing import List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def is_newer(a: int, b: int) -> bool:
|
def is_newer(a: int, b: int) -> bool:
|
||||||
"""Return True if 16-bit sequence number a is newer than b (wrap-aware).
|
"""Return True if 16-bit sequence number a is newer than b (wrap-aware).
|
||||||
@@ -8,3 +14,129 @@ def is_newer(a: int, b: int) -> bool:
|
|||||||
"""
|
"""
|
||||||
return ((a - b) & 0xFFFF) < 0x8000
|
return ((a - b) & 0xFFFF) < 0x8000
|
||||||
|
|
||||||
|
|
||||||
|
def get_cert_sha256_hash(cert_path: str) -> str:
|
||||||
|
"""Calculate SHA-256 hash of a certificate file.
|
||||||
|
|
||||||
|
Returns the hash as a lowercase hex string (64 characters).
|
||||||
|
This hash can be used by WebTransport clients for certificate pinning.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
|
||||||
|
# Read the certificate file
|
||||||
|
with open(cert_path, 'rb') as f:
|
||||||
|
cert_data = f.read()
|
||||||
|
|
||||||
|
# Parse the certificate
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_data, default_backend())
|
||||||
|
|
||||||
|
# Get the DER-encoded certificate and hash it
|
||||||
|
cert_der = cert.public_bytes(encoding=x509.Encoding.DER)
|
||||||
|
hash_bytes = hashlib.sha256(cert_der).digest()
|
||||||
|
|
||||||
|
# Return as hex string
|
||||||
|
return hash_bytes.hex()
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback: just hash the file contents (less accurate but works)
|
||||||
|
with open(cert_path, 'rb') as f:
|
||||||
|
return hashlib.sha256(f.read()).digest().hex()
|
||||||
|
|
||||||
|
|
||||||
|
def split_pem_certificates(pem_data: str) -> List[str]:
|
||||||
|
"""Split a PEM file containing multiple certificates into individual certs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pem_data: String containing one or more PEM certificates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of individual certificate strings (each includes BEGIN/END markers)
|
||||||
|
"""
|
||||||
|
# Match all certificate blocks
|
||||||
|
cert_pattern = r'(-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----)'
|
||||||
|
certificates = re.findall(cert_pattern, pem_data, re.DOTALL)
|
||||||
|
return certificates
|
||||||
|
|
||||||
|
|
||||||
|
def combine_cert_chain(cert_path: str, chain_paths: List[str]) -> str:
|
||||||
|
"""Combine server cert and chain certs into a single properly-formatted PEM file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cert_path: Path to server certificate (may contain full chain)
|
||||||
|
chain_paths: List of paths to intermediate/root certificates (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to temporary combined certificate file (caller should clean up)
|
||||||
|
"""
|
||||||
|
combined_content = []
|
||||||
|
|
||||||
|
# Read server certificate file
|
||||||
|
with open(cert_path, 'r') as f:
|
||||||
|
cert_data = f.read().strip()
|
||||||
|
certs = split_pem_certificates(cert_data)
|
||||||
|
|
||||||
|
if chain_paths:
|
||||||
|
# If chain files provided separately, only use first cert from cert_path
|
||||||
|
if certs:
|
||||||
|
combined_content.append(certs[0].strip())
|
||||||
|
else:
|
||||||
|
combined_content.append(cert_data)
|
||||||
|
else:
|
||||||
|
# No separate chain files - include all certs from cert_path (fullchain case)
|
||||||
|
if certs:
|
||||||
|
combined_content.extend([c.strip() for c in certs])
|
||||||
|
else:
|
||||||
|
combined_content.append(cert_data)
|
||||||
|
|
||||||
|
# Read separate chain certificate files
|
||||||
|
for chain_path in chain_paths:
|
||||||
|
with open(chain_path, 'r') as f:
|
||||||
|
chain_data = f.read().strip()
|
||||||
|
# Each chain file might contain multiple certs
|
||||||
|
chain_certs = split_pem_certificates(chain_data)
|
||||||
|
if chain_certs:
|
||||||
|
combined_content.extend([c.strip() for c in chain_certs])
|
||||||
|
else:
|
||||||
|
combined_content.append(chain_data)
|
||||||
|
|
||||||
|
# Write to temporary file with proper formatting
|
||||||
|
# Each cert should be separated by a newline
|
||||||
|
temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.pem', delete=False)
|
||||||
|
temp_file.write('\n'.join(combined_content))
|
||||||
|
temp_file.write('\n') # End with newline
|
||||||
|
temp_file.close()
|
||||||
|
|
||||||
|
return temp_file.name
|
||||||
|
|
||||||
|
|
||||||
|
def extract_server_cert(cert_path: str) -> str:
|
||||||
|
"""Extract just the server certificate from a file that may contain a full chain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cert_path: Path to certificate file (may contain chain)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to temporary file containing only the server certificate
|
||||||
|
"""
|
||||||
|
with open(cert_path, 'r') as f:
|
||||||
|
cert_data = f.read()
|
||||||
|
|
||||||
|
# Split into individual certificates
|
||||||
|
certs = split_pem_certificates(cert_data)
|
||||||
|
|
||||||
|
if not certs:
|
||||||
|
# No certs found, return original path
|
||||||
|
return cert_path
|
||||||
|
|
||||||
|
# First cert is the server cert
|
||||||
|
server_cert = certs[0].strip()
|
||||||
|
|
||||||
|
# Write to temporary file
|
||||||
|
temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.pem', delete=False)
|
||||||
|
temp_file.write(server_cert)
|
||||||
|
temp_file.write('\n')
|
||||||
|
temp_file.close()
|
||||||
|
|
||||||
|
return temp_file.name
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Awaitable, Callable, Dict, Optional
|
from typing import Callable, Dict, Optional
|
||||||
|
|
||||||
from .transport import DatagramServerTransport, OnDatagram, TransportPeer
|
from .transport import DatagramServerTransport, OnDatagram, TransportPeer
|
||||||
import logging
|
import logging
|
||||||
@@ -11,7 +11,8 @@ import logging
|
|||||||
try:
|
try:
|
||||||
from aioquic.asyncio import QuicConnectionProtocol, serve
|
from aioquic.asyncio import QuicConnectionProtocol, serve
|
||||||
from aioquic.quic.configuration import QuicConfiguration
|
from aioquic.quic.configuration import QuicConfiguration
|
||||||
from aioquic.h3.connection import H3Connection
|
from aioquic.quic.events import ProtocolNegotiated
|
||||||
|
from aioquic.h3.connection import H3_ALPN, H3Connection
|
||||||
from aioquic.h3.events import HeadersReceived
|
from aioquic.h3.events import HeadersReceived
|
||||||
# Datagram event names vary by aioquic version; try both
|
# Datagram event names vary by aioquic version; try both
|
||||||
try:
|
try:
|
||||||
@@ -21,6 +22,8 @@ try:
|
|||||||
except Exception: # pragma: no cover - optional dependency not installed
|
except Exception: # pragma: no cover - optional dependency not installed
|
||||||
QuicConnectionProtocol = object # type: ignore
|
QuicConnectionProtocol = object # type: ignore
|
||||||
QuicConfiguration = object # type: ignore
|
QuicConfiguration = object # type: ignore
|
||||||
|
ProtocolNegotiated = object # type: ignore
|
||||||
|
H3_ALPN = [] # type: ignore
|
||||||
H3Connection = object # type: ignore
|
H3Connection = object # type: ignore
|
||||||
HeadersReceived = object # type: ignore
|
HeadersReceived = object # type: ignore
|
||||||
H3DatagramReceived = object # type: ignore
|
H3DatagramReceived = object # type: ignore
|
||||||
@@ -38,55 +41,75 @@ class GameWTProtocol(QuicConnectionProtocol): # type: ignore[misc]
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._on_datagram = on_datagram
|
self._on_datagram = on_datagram
|
||||||
self._sessions = sessions
|
self._sessions = sessions
|
||||||
self._http: Optional[H3Connection] = None
|
self._http: Optional[H3Connection] = None # Created after ProtocolNegotiated
|
||||||
|
logging.debug("GameWTProtocol initialized")
|
||||||
|
|
||||||
def http_event_received(self, event) -> None: # type: ignore[override]
|
def http_event_received(self, event) -> None: # type: ignore[override]
|
||||||
|
logging.debug("HTTP event received: %s", type(event).__name__)
|
||||||
# Headers for CONNECT :protocol = webtransport open a session
|
# Headers for CONNECT :protocol = webtransport open a session
|
||||||
if isinstance(event, HeadersReceived):
|
if isinstance(event, HeadersReceived):
|
||||||
headers = {k.decode().lower(): v.decode() for k, v in event.headers}
|
headers = {k.decode().lower(): v.decode() for k, v in event.headers}
|
||||||
|
logging.debug("HeadersReceived: %s", headers)
|
||||||
method = headers.get(":method")
|
method = headers.get(":method")
|
||||||
protocol = headers.get(":protocol") or headers.get("sec-webtransport-protocol")
|
protocol = headers.get(":protocol") or headers.get("sec-webtransport-protocol")
|
||||||
|
logging.debug("Method: %s, Protocol: %s", method, protocol)
|
||||||
if method == "CONNECT" and (protocol == "webtransport"):
|
if method == "CONNECT" and (protocol == "webtransport"):
|
||||||
# In WebTransport over H3, datagrams use the CONNECT stream id as flow_id
|
# In WebTransport over H3, datagrams use the CONNECT stream id as session id
|
||||||
flow_id = event.stream_id # type: ignore[attr-defined]
|
session_id = event.stream_id # type: ignore[attr-defined]
|
||||||
self._sessions[flow_id] = WTSession(flow_id=flow_id, proto=self)
|
self._sessions[session_id] = WTSession(flow_id=session_id, proto=self)
|
||||||
logging.info("WT CONNECT accepted: flow_id=%s", flow_id)
|
logging.info("WT CONNECT accepted: session_id=%s", session_id)
|
||||||
# Send 2xx to accept the session
|
# Send 2xx to accept the session
|
||||||
if self._http is not None:
|
if self._http is not None:
|
||||||
self._http.send_headers(event.stream_id, [(b":status", b"200")])
|
self._http.send_headers(event.stream_id, [(b":status", b"200")])
|
||||||
|
self.transmit() # Actually send the response to complete handshake
|
||||||
|
logging.debug("Sent 200 response for WT CONNECT")
|
||||||
|
else:
|
||||||
|
logging.warning("Unexpected CONNECT: method=%s, protocol=%s", method, protocol)
|
||||||
elif isinstance(event, H3DatagramReceived): # type: ignore[misc]
|
elif isinstance(event, H3DatagramReceived): # type: ignore[misc]
|
||||||
# Route datagram to session by flow_id
|
# Route datagram to session by stream_id (WebTransport session ID)
|
||||||
flow_id = getattr(event, "flow_id", None)
|
# DatagramReceived has: stream_id (session), data (payload)
|
||||||
|
session_id = getattr(event, "stream_id", None)
|
||||||
data = getattr(event, "data", None)
|
data = getattr(event, "data", None)
|
||||||
if flow_id is None or data is None:
|
logging.debug("DatagramReceived event: session_id=%s, data_len=%s, has_data=%s",
|
||||||
|
session_id, len(data) if data else None, data is not None)
|
||||||
|
if session_id is None or data is None:
|
||||||
|
logging.warning("Invalid datagram event: session_id=%s, data=%s", session_id, data)
|
||||||
return
|
return
|
||||||
sess = self._sessions.get(flow_id)
|
sess = self._sessions.get(session_id)
|
||||||
if not sess:
|
if not sess:
|
||||||
|
logging.warning("No session found for datagram: session_id=%s", session_id)
|
||||||
return
|
return
|
||||||
peer = TransportPeer(addr=(self, flow_id))
|
peer = TransportPeer(addr=(self, session_id))
|
||||||
logging.debug("WT datagram received: flow_id=%s, %d bytes", flow_id, len(data))
|
logging.info("WT datagram received: session_id=%s, %d bytes", session_id, len(data))
|
||||||
asyncio.ensure_future(self._on_datagram(bytes(data), peer))
|
asyncio.ensure_future(self._on_datagram(bytes(data), peer))
|
||||||
|
|
||||||
def quic_event_received(self, event) -> None: # type: ignore[override]
|
def quic_event_received(self, event) -> None: # type: ignore[override]
|
||||||
# Lazily create H3 connection wrapper
|
event_name = type(event).__name__
|
||||||
if self._http is None and hasattr(self, "_quic"):
|
logging.debug("QUIC event received: %s", event_name)
|
||||||
try:
|
|
||||||
self._http = H3Connection(self._quic, enable_webtransport=True) # type: ignore[attr-defined]
|
# Create H3Connection after ALPN protocol is negotiated
|
||||||
except Exception:
|
if isinstance(event, ProtocolNegotiated):
|
||||||
self._http = None
|
if event.alpn_protocol in H3_ALPN:
|
||||||
|
self._http = H3Connection(self._quic, enable_webtransport=True)
|
||||||
|
logging.info("H3Connection created with WebTransport support (ALPN: %s)", event.alpn_protocol)
|
||||||
|
else:
|
||||||
|
logging.warning("Unexpected ALPN protocol: %s", event.alpn_protocol)
|
||||||
|
|
||||||
|
# Pass event to HTTP layer if connection is established
|
||||||
if self._http is not None:
|
if self._http is not None:
|
||||||
for http_event in self._http.handle_event(event):
|
for http_event in self._http.handle_event(event):
|
||||||
self.http_event_received(http_event)
|
self.http_event_received(http_event)
|
||||||
|
|
||||||
async def send_h3_datagram(self, flow_id: int, data: bytes) -> None:
|
async def send_h3_datagram(self, flow_id: int, data: bytes) -> None:
|
||||||
if self._http is None:
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
|
if self._http is None:
|
||||||
|
logging.warning("Cannot send datagram: H3Connection not established")
|
||||||
|
return
|
||||||
logging.debug("WT send datagram: flow_id=%s, %d bytes", flow_id, len(data))
|
logging.debug("WT send datagram: flow_id=%s, %d bytes", flow_id, len(data))
|
||||||
self._http.send_datagram(flow_id, data)
|
self._http.send_datagram(stream_id=flow_id, data=data)
|
||||||
await self._loop.run_in_executor(None, self.transmit) # type: ignore[attr-defined]
|
self.transmit() # Send queued QUIC packets immediately
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logging.debug("Failed to send datagram: %s", e)
|
||||||
|
|
||||||
|
|
||||||
class WebTransportServer(DatagramServerTransport):
|
class WebTransportServer(DatagramServerTransport):
|
||||||
@@ -115,15 +138,34 @@ class WebTransportServer(DatagramServerTransport):
|
|||||||
await proto.send_h3_datagram(flow_id, data)
|
await proto.send_h3_datagram(flow_id, data)
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
configuration = QuicConfiguration(is_client=False, alpn_protocols=["h3"])
|
from aioquic.quic.logger import QuicFileLogger
|
||||||
configuration.load_cert_chain(self.certfile, self.keyfile)
|
import os
|
||||||
|
|
||||||
async def _create_protocol(*args, **kwargs):
|
# Enable QUIC logging if debug level
|
||||||
|
quic_logger = None
|
||||||
|
if logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
|
||||||
|
log_dir = os.path.join(os.getcwd(), "quic_logs")
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
quic_logger = QuicFileLogger(log_dir)
|
||||||
|
logging.info("QUIC logging enabled in: %s", log_dir)
|
||||||
|
|
||||||
|
configuration = QuicConfiguration(
|
||||||
|
is_client=False,
|
||||||
|
alpn_protocols=["h3"], # HTTP/3 with WebTransport support
|
||||||
|
max_datagram_frame_size=65536, # Enable QUIC datagrams (required for WebTransport)
|
||||||
|
quic_logger=quic_logger
|
||||||
|
)
|
||||||
|
configuration.load_cert_chain(self.certfile, self.keyfile)
|
||||||
|
logging.debug("QUIC configuration: ALPN=%s, max_datagram_frame_size=%d",
|
||||||
|
configuration.alpn_protocols, configuration.max_datagram_frame_size or 0)
|
||||||
|
|
||||||
|
def _create_protocol(*args, **kwargs):
|
||||||
return GameWTProtocol(*args, on_datagram=self._on_datagram, sessions=self._sessions, **kwargs)
|
return GameWTProtocol(*args, on_datagram=self._on_datagram, sessions=self._sessions, **kwargs)
|
||||||
|
|
||||||
logging.info("WebTransport (H3) server listening on %s:%d", self.host, self.port)
|
logging.info("WebTransport (H3) server listening on %s:%d", self.host, self.port)
|
||||||
self._server = await serve(self.host, self.port, configuration=configuration, create_protocol=_create_protocol)
|
self._server = await serve(self.host, self.port, configuration=configuration, create_protocol=_create_protocol)
|
||||||
try:
|
try:
|
||||||
await self._server.wait_closed()
|
# Wait indefinitely until cancelled (e.g., by KeyboardInterrupt or timeout)
|
||||||
|
await asyncio.Event().wait()
|
||||||
finally:
|
finally:
|
||||||
self._server.close()
|
self._server.close()
|
||||||
|
|||||||
59
test_http3.py
Normal file
59
test_http3.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Simple HTTP/3 client to test the server."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from aioquic.asyncio import connect
|
||||||
|
from aioquic.quic.configuration import QuicConfiguration
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
async def test_http3(url: str):
|
||||||
|
"""Test HTTP/3 connection to the server."""
|
||||||
|
print(f"Testing HTTP/3 connection to: {url}")
|
||||||
|
|
||||||
|
# Parse URL
|
||||||
|
if url.startswith("https://"):
|
||||||
|
url = url[8:]
|
||||||
|
|
||||||
|
host, port = url.split(":")
|
||||||
|
port = int(port.rstrip("/"))
|
||||||
|
|
||||||
|
print(f"Connecting to {host}:{port}...")
|
||||||
|
|
||||||
|
# Create QUIC configuration
|
||||||
|
configuration = QuicConfiguration(
|
||||||
|
is_client=True,
|
||||||
|
alpn_protocols=["h3"],
|
||||||
|
verify_mode=0 # Skip certificate verification for testing
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with connect(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
configuration=configuration,
|
||||||
|
) as protocol:
|
||||||
|
print(f"✓ QUIC connection established!")
|
||||||
|
print(f" ALPN protocol: {protocol._quic.tls.alpn_negotiated}")
|
||||||
|
print(f" Remote address: {protocol._quic.remote_address}")
|
||||||
|
|
||||||
|
# Just test the connection, don't send HTTP requests yet
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
print("✓ Connection successful!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Connection failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
url = sys.argv[1] if len(sys.argv) > 1 else "https://127.0.0.1:4433"
|
||||||
|
|
||||||
|
success = asyncio.run(test_http3(url))
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
188
test_webtransport_client.py
Normal file
188
test_webtransport_client.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
WebTransport test client to verify server is working correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aioquic.asyncio.client import connect
|
||||||
|
from aioquic.asyncio.protocol import QuicConnectionProtocol
|
||||||
|
from aioquic.h3.connection import H3Connection
|
||||||
|
from aioquic.h3.events import DatagramReceived, H3Event, HeadersReceived
|
||||||
|
from aioquic.quic.configuration import QuicConfiguration
|
||||||
|
from aioquic.quic.events import QuicEvent
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="[%(asctime)s] %(levelname)s: %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebTransportClient(QuicConnectionProtocol):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._http: Optional[H3Connection] = None
|
||||||
|
self._session_id: Optional[int] = None
|
||||||
|
self._session_established = asyncio.Event()
|
||||||
|
self._received_datagrams = []
|
||||||
|
|
||||||
|
def quic_event_received(self, event: QuicEvent) -> None:
|
||||||
|
logger.debug(f"QUIC event: {type(event).__name__}")
|
||||||
|
|
||||||
|
# Create H3Connection on first event
|
||||||
|
if self._http is None:
|
||||||
|
self._http = H3Connection(self._quic, enable_webtransport=True)
|
||||||
|
logger.info("H3Connection created with WebTransport support")
|
||||||
|
|
||||||
|
# Process event through HTTP/3 layer
|
||||||
|
if self._http is not None:
|
||||||
|
for http_event in self._http.handle_event(event):
|
||||||
|
self.http_event_received(http_event)
|
||||||
|
|
||||||
|
def http_event_received(self, event: H3Event) -> None:
|
||||||
|
logger.debug(f"HTTP event: {type(event).__name__}")
|
||||||
|
|
||||||
|
if isinstance(event, HeadersReceived):
|
||||||
|
headers = dict(event.headers)
|
||||||
|
status = headers.get(b":status", b"").decode()
|
||||||
|
logger.info(f"Received headers: status={status}")
|
||||||
|
|
||||||
|
if status == "200" and self._session_id is None:
|
||||||
|
self._session_id = event.stream_id
|
||||||
|
logger.info(f"WebTransport session established! session_id={self._session_id}")
|
||||||
|
self._session_established.set()
|
||||||
|
else:
|
||||||
|
logger.error(f"WebTransport session rejected: status={status}, headers={headers}")
|
||||||
|
|
||||||
|
elif isinstance(event, DatagramReceived):
|
||||||
|
logger.info(f"Received datagram: {len(event.data)} bytes, flow_id={event.flow_id}")
|
||||||
|
self._received_datagrams.append(event.data)
|
||||||
|
|
||||||
|
async def establish_session(self, path: str = "/") -> None:
|
||||||
|
"""Send WebTransport CONNECT request."""
|
||||||
|
logger.info(f"Sending WebTransport CONNECT request for path: {path}")
|
||||||
|
|
||||||
|
# Allocate stream ID for CONNECT request
|
||||||
|
stream_id = self._quic.get_next_available_stream_id()
|
||||||
|
|
||||||
|
# Send CONNECT request with WebTransport protocol
|
||||||
|
headers = [
|
||||||
|
(b":method", b"CONNECT"),
|
||||||
|
(b":scheme", b"https"),
|
||||||
|
(b":authority", b"np.vaku.org.ua:4433"),
|
||||||
|
(b":path", path.encode()),
|
||||||
|
(b":protocol", b"webtransport"),
|
||||||
|
]
|
||||||
|
|
||||||
|
self._http.send_headers(stream_id=stream_id, headers=headers, end_stream=False)
|
||||||
|
self.transmit()
|
||||||
|
|
||||||
|
logger.info(f"WebTransport CONNECT sent on stream {stream_id}")
|
||||||
|
|
||||||
|
# Wait for session to be established
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._session_established.wait(), timeout=5.0)
|
||||||
|
logger.info("✓ WebTransport session established successfully!")
|
||||||
|
return True
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("✗ Timeout waiting for WebTransport session acceptance")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_datagram(self, data: bytes) -> None:
|
||||||
|
"""Send a datagram over the WebTransport session."""
|
||||||
|
if self._session_id is None:
|
||||||
|
logger.error("Cannot send datagram: session not established")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Sending datagram: {len(data)} bytes")
|
||||||
|
self._http.send_datagram(stream_id=self._session_id, data=data)
|
||||||
|
self.transmit()
|
||||||
|
|
||||||
|
async def wait_for_datagram(self, timeout: float = 2.0) -> Optional[bytes]:
|
||||||
|
"""Wait for a datagram response."""
|
||||||
|
logger.info(f"Waiting for datagram response (timeout={timeout}s)...")
|
||||||
|
start = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
while asyncio.get_event_loop().time() - start < timeout:
|
||||||
|
if self._received_datagrams:
|
||||||
|
return self._received_datagrams.pop(0)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
logger.warning("No datagram received within timeout")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webtransport(host: str, port: int, verify_mode: bool = False):
|
||||||
|
"""Test WebTransport connection to the server."""
|
||||||
|
logger.info(f"Testing WebTransport connection to {host}:{port}")
|
||||||
|
|
||||||
|
# Configure QUIC
|
||||||
|
configuration = QuicConfiguration(
|
||||||
|
is_client=True,
|
||||||
|
alpn_protocols=["h3"], # HTTP/3
|
||||||
|
max_datagram_frame_size=65536, # Enable QUIC datagrams (required for WebTransport)
|
||||||
|
verify_mode=0 if not verify_mode else 2, # Skip cert verification for self-signed
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with connect(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
configuration=configuration,
|
||||||
|
create_protocol=WebTransportClient,
|
||||||
|
) as client:
|
||||||
|
logger.info("✓ QUIC connection established")
|
||||||
|
client = client # type: WebTransportClient
|
||||||
|
|
||||||
|
# Establish WebTransport session
|
||||||
|
if not await client.establish_session("/"):
|
||||||
|
logger.error("✗ Failed to establish WebTransport session")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Send a test datagram (simple game JOIN packet)
|
||||||
|
# Format: version(1) | type(1) | flags(1) | seq(2) | name_len(1) | name
|
||||||
|
test_packet = bytes([
|
||||||
|
0x01, # version
|
||||||
|
0x01, # JOIN packet type
|
||||||
|
0x00, # flags
|
||||||
|
0x00, 0x01, # seq=1
|
||||||
|
0x04, # name length
|
||||||
|
ord('T'), ord('E'), ord('S'), ord('T'), # name="TEST"
|
||||||
|
])
|
||||||
|
|
||||||
|
await client.send_datagram(test_packet)
|
||||||
|
logger.info("✓ Test JOIN packet sent")
|
||||||
|
|
||||||
|
# Wait for response
|
||||||
|
response = await client.wait_for_datagram(timeout=3.0)
|
||||||
|
if response:
|
||||||
|
logger.info(f"✓ Received response: {len(response)} bytes")
|
||||||
|
logger.info(f" Response hex: {response.hex()}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning("Server did not respond to JOIN packet (might be normal if not implemented)")
|
||||||
|
return True # Connection worked even if no response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"✗ Connection failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python test_webtransport_client.py <host> [port]")
|
||||||
|
print("Example: python test_webtransport_client.py np.vaku.org.ua 4433")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
host = sys.argv[1]
|
||||||
|
port = int(sys.argv[2]) if len(sys.argv) > 2 else 4433
|
||||||
|
|
||||||
|
success = asyncio.run(test_webtransport(host, port))
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
Reference in New Issue
Block a user