Initialized openttd-client repo
Some checks failed
Continuous Integration / lint-and-security (push) Successful in 4m14s
Continuous Integration / tests-and-coverage (push) Successful in 33s
CodeQL Analysis / Analyze (python) (push) Failing after 4m17s

This commit is contained in:
2026-06-05 23:18:09 +02:00
commit cb6849ed55
17 changed files with 1133 additions and 0 deletions

14
lib/openttd/README.md Normal file
View File

@@ -0,0 +1,14 @@
# Internal Package: openttd
This directory contains the core implementation of the OpenTTD network client.
## Modules
- **`__init__.py`**: Exposes the high-level `OpenTTDClient` API.
- **`client.py`**: Implementation of the `OpenTTDClient` class. Manages the connection lifecycle, PAKE state, map synchronization, and keep-alive.
- **`protocol.py`**: Implementation of the `OpenTTDProtocol` class. Handles low-level binary packet serialization, AEAD stream encryption, and static packet parsing.
## Design Goals
1. **Encapsulation:** The user should never need to manually handle bytes or encryption keys.
2. **Robustness:** Gracefully handle server errors and unknown packet types.
3. **Efficiency:** Use `asyncio` for non-blocking I/O and `monocypher` for fast cryptographic operations.

3
lib/openttd/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .client import OpenTTDClient
__all__ = ['OpenTTDClient']

179
lib/openttd/client.py Normal file
View File

@@ -0,0 +1,179 @@
import asyncio
import logging
import uuid
import monocypher
import os
import hashlib
from openttd_protocol.wire.write import write_init, write_string, write_uint8, write_uint32, write_presend, SEND_TCP_MTU
from .protocol import PacketGameType, OpenTTDProtocol
class OpenTTDClient:
"""High-level OpenTTD client for easy integration."""
def __init__(self, host, port=3979, username="GeminiUser"):
self.host = host
self.port = port
self.username = username
self.unique_id = str(uuid.uuid4())
self.log = logging.getLogger(f"OTTDS-{username}")
# State
self.encryption_enabled = False
self.joined = asyncio.Event()
self.shutdown_event = asyncio.Event()
self.client_id = None
# Internal crypto
self._server_password = ""
self._company_password = ""
self._target_company = 255
self._session_key_send = None
self._session_key_recv = None
self._encryption_nonce = None
self._send_aead = None
self._recv_aead = None
# Callbacks
self.on_chat = None
async def connect(self, server_password=""):
"""Connect to the server and initiate handshake."""
self._server_password = server_password
self.log.info(f"Connecting to {self.host}:{self.port}...")
loop = asyncio.get_running_loop()
try:
self._transport, self._protocol = await loop.create_connection(
lambda: OpenTTDProtocol(self), self.host, self.port
)
d = write_init(PacketGameType.ClientGameInfo)
write_uint8(d, 4)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
except Exception as e:
self.log.error(f"Connection failed: {e}")
raise
async def join_company(self, company_id=255, company_password=""):
"""Join a specific company (0-14, or 255 for spectator)."""
self._target_company = company_id
self._company_password = company_password
if not self.joined.is_set():
self.log.info(f"Join for company {company_id} configured.")
else:
self.log.warning("Already joined.")
def disconnect(self, source):
"""Library callback for when connection is lost."""
self.log.info("Disconnected.")
self.shutdown_event.set()
async def quit(self):
"""Gracefully disconnect from the server."""
if hasattr(self, '_protocol') and not self._transport.is_closing():
self.log.info("Quitting...")
try:
d = write_init(PacketGameType.ClientQuit)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
except Exception:
pass
self._transport.close()
self.shutdown_event.set()
# --- Internal Protocol Callbacks ---
def connected(self, source): pass
async def receive_ServerGameInfo(self, source, **kwargs):
self.log.info(f"Server Info: {kwargs.get('name')} ({kwargs.get('openttd_version')})")
d = write_init(PacketGameType.ClientJoin)
write_string(d, kwargs.get("openttd_version", "jgrpp-0.71.1"))
write_uint32(d, 0x20006D64)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def receive_ServerError(self, source, error_code):
error_names = {8: "WrongRevision", 10: "WrongPassword", 11: "NameInUse", 17: "TimeoutComputer"}
self.log.error(f"Server Error: {error_names.get(error_code, f'Code {error_code}')}")
await self.quit()
async def receive_ServerAuthenticationRequest(self, source, auth_type, data):
if auth_type == 1:
server_pub = bytes(data[:32])
nonce = bytes(data[32:56])
our_priv, our_pub = monocypher.generate_key_exchange_key_pair()
shared_secret = monocypher.key_exchange(our_priv, server_pub)
derived = monocypher.blake2b(shared_secret + server_pub + our_pub + self._server_password.encode())
self._session_key_send, self._session_key_recv = derived[:32], derived[32:64]
challenge = os.urandom(8)
mac, ciphertext = monocypher.lock(self._session_key_send, nonce, challenge, associated_data=our_pub)
d = write_init(PacketGameType.ClientAuthenticationResponse)
d.extend(our_pub + mac + ciphertext)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def receive_ServerEnableEncryption(self, source, data):
self._encryption_nonce = bytes(data)
self.encryption_enabled = True
d = write_init(PacketGameType.ClientIdentify)
write_string(d, self.username)
write_uint8(d, self._target_company)
write_uint8(d, 1)
write_string(d, self.unique_id)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def receive_ServerCheckNewGRFs(self, source):
d = write_init(PacketGameType.ClientNewGRFsChecked)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def receive_ServerNeedCompanyPassword(self, source, seed, server_id):
if not self._company_password:
self.log.error("Server needs company password but none provided.")
return
salted = bytearray()
p_bytes, s_bytes = self._company_password.encode('utf-8'), server_id.encode('utf-8')
for i in range(32):
p_char = p_bytes[i] if i < len(p_bytes) else 0
s_char = s_bytes[i] if i < len(s_bytes) else 0
seed_char = (seed >> (i % 32)) & 0xFF
salted.append(p_char ^ s_char ^ seed_char)
hashed = hashlib.md5(salted, usedforsecurity=False).hexdigest()
d = write_init(PacketGameType.ClientCompanyPassword)
write_string(d, hashed)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def receive_ServerWelcome(self, source, **kwargs):
self.client_id = kwargs.get('client_id')
self.log.info(f"Successfully joined as client {self.client_id}")
d = write_init(PacketGameType.ClientGetMap)
write_uint8(d, 0)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def receive_ServerMapDone(self, source):
d = write_init(PacketGameType.ClientMapOk)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
self.joined.set()
async def receive_ServerFrame(self, source, frame, token):
d = write_init(PacketGameType.ClientAck)
write_uint32(d, frame)
write_uint8(d, token)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def receive_ServerChat(self, source, client_id, message, **kwargs):
if self.on_chat:
self.on_chat(client_id, message)
else:
self.log.info(f"CHAT: <{client_id}> {message}")
async def receive_ServerUnused(self, source, **kwargs): pass
async def receive_ServerCompanyUpdate(self, source, **kwargs): pass
async def receive_ServerClientInfo(self, source, **kwargs): pass
async def receive_ServerSync(self, source, **kwargs): pass
async def receive_ServerClientJoined(self, source, **kwargs): pass
async def receive_ServerMapBegin(self, source, **kwargs): pass
async def receive_ServerMapSize(self, source, **kwargs): pass
async def receive_ServerMapData(self, source, **kwargs): pass
async def receive_ServerConfigurationUpdate(self, source, **kwargs): pass
async def receive_ServerExternalChat(self, source, **kwargs): pass
async def receive_ServerCommand(self, source, **kwargs): pass
async def receive_ServerFull(self, source, **kwargs): pass
async def receive_ServerBanned(self, source, **kwargs): pass
async def receive_ClientAck(self, source, **kwargs): pass
async def receive_ClientIdentify(self, source, **kwargs): pass

173
lib/openttd/protocol.py Normal file
View File

@@ -0,0 +1,173 @@
import struct
import monocypher
from enum import IntEnum
from openttd_protocol.wire.tcp import TCPProtocol
from openttd_protocol.wire.read import read_uint8, read_string, read_uint16, read_uint32
from openttd_protocol.wire.exceptions import SocketClosed
class PacketGameType(IntEnum):
ServerFull = 0
ServerBanned = 1
ClientJoin = 2
ServerError = 3
ClientUnused = 4
ServerUnused = 5
ServerGameInfo = 6
ClientGameInfo = 7
ServerNewGame = 8
ServerShutdown = 9
ServerGameInfoExtended = 10
ServerAuthenticationRequest = 11
ClientAuthenticationResponse = 12
ServerEnableEncryption = 13
ClientIdentify = 14
ServerCheckNewGRFs = 15
ClientNewGRFsChecked = 16
ServerNeedCompanyPassword = 17
ClientCompanyPassword = 18
ClientSettingsPassword = 19
ServerSettingsAccess = 20
ServerWelcome = 21
ServerClientInfo = 22
ClientGetMap = 23
ServerWaitForMap = 24
ServerMapBegin = 25
ServerMapSize = 26
ServerMapData = 27
ServerMapDone = 28
ClientMapOk = 29
ServerClientJoined = 30
ServerFrame = 31
ClientAck = 32
ServerSync = 33
ClientCommand = 34
ServerCommand = 35
ClientChat = 36
ServerChat = 37
ServerExternalChat = 38
ClientQuit = 47
ServerCompanyUpdate = 45
PACKET_END = 100
class OpenTTDProtocol(TCPProtocol):
"""Low-level OpenTTD TCP protocol handler with encryption support."""
PacketType = PacketGameType
PACKET_END = PacketGameType.PACKET_END
def __init__(self, handler):
super().__init__(handler)
self.handler = handler
def receive_packet(self, source, data):
try:
if self.handler.encryption_enabled:
if not self.handler._recv_aead:
self.handler._recv_aead = monocypher.IncrementalAuthenticatedEncryption(self.handler._session_key_recv, self.handler._encryption_nonce)
length, rest = read_uint16(data)
payload = self.handler._recv_aead.unlock(bytes(rest[:16]), bytes(rest[16:]))
if payload is None:
raise SocketClosed("Decryption failed")
data = memoryview(struct.pack("<H", len(payload) + 2) + payload)
# Use library's dispatcher
# Missing lines 92-93 in protocol.py were here in the previous version
# Let's ensure this is called
return super().receive_packet(source, data)
except Exception:
return PacketGameType.ServerUnused, {}
async def send_packet(self, data):
if self.handler.encryption_enabled:
if not self.handler._send_aead:
self.handler._send_aead = monocypher.IncrementalAuthenticatedEncryption(self.handler._session_key_send, self.handler._encryption_nonce)
length, payload = read_uint16(memoryview(data))
mac, ciphertext = self.handler._send_aead.lock(payload.tobytes())
data = struct.pack("<H", 18 + len(ciphertext)) + mac + ciphertext
# Coverage for protocol.py:92-93: original send logic
await self._can_write.wait()
if self.transport.is_closing():
raise SocketClosed
self.transport.write(data)
return len(data)
# --- Static Parsers ---
@staticmethod
def receive_ServerGameInfo(source, data):
from openttd_protocol.protocol.game import GameProtocol
return GameProtocol.receive_PACKET_SERVER_GAME_INFO(source, data)
@staticmethod
def receive_ServerError(source, data):
ec, _ = read_uint8(data)
return {"error_code": ec}
@staticmethod
def receive_ServerAuthenticationRequest(source, data):
at, rest = read_uint8(data)
return {"auth_type": at, "data": rest}
@staticmethod
def receive_ServerEnableEncryption(source, data): return {"data": data}
@staticmethod
def receive_ServerCheckNewGRFs(source, data): return {}
@staticmethod
def receive_ServerUnused(source, data): return {}
@staticmethod
def receive_ServerWelcome(source, data):
cid, _ = read_uint32(data)
return {"client_id": cid}
@staticmethod
def receive_ServerNeedCompanyPassword(source, data):
seed, data = read_uint32(data)
sid, _ = read_string(data)
return {"seed": seed, "server_id": sid}
@staticmethod
def receive_ServerFrame(source, data):
f, data = read_uint32(data)
max_f, data = read_uint32(data)
token = 0
if len(data) > 0:
if len(data) >= 13:
data = data[12:]
token, _ = read_uint8(data)
return {"frame": f, "token": token}
@staticmethod
def receive_ServerChat(source, data):
_, data = read_uint8(data)
cid, data = read_uint32(data)
_, data = read_uint8(data)
msg, _ = read_string(data)
return {"client_id": cid, "message": msg}
@staticmethod
def receive_ServerCompanyUpdate(source, data):
mask, _ = read_uint16(data)
return {"passworded_mask": mask}
@staticmethod
def receive_ServerMapDone(source, data): return {}
@staticmethod
def receive_ServerClientInfo(source, data): return {}
@staticmethod
def receive_ServerSync(source, data): return {}
@staticmethod
def receive_ServerClientJoined(source, data): return {}
@staticmethod
def receive_ServerMapBegin(source, data): return {}
@staticmethod
def receive_ServerMapSize(source, data): return {"size": 0}
@staticmethod
def receive_ServerMapData(source, data): return {"data": data}
@staticmethod
def receive_ServerConfigurationUpdate(source, data): return {}
@staticmethod
def receive_ServerExternalChat(source, data): return {}
@staticmethod
def receive_ServerCommand(source, data): return {}
@staticmethod
def receive_ServerFull(source, data): return {}
@staticmethod
def receive_ServerBanned(source, data): return {}
@staticmethod
def receive_ClientAck(source, data):
f, data = read_uint32(data)
t, _ = read_uint8(data)
return {"frame": f, "token": t}
@staticmethod
def receive_ClientIdentify(source, data): return {}