Add admin port support with other major refactorations
Some checks failed
Continuous Integration / lint-and-security (pull_request) Failing after 39s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s

This commit is contained in:
2026-06-29 19:47:48 +02:00
parent df629b2922
commit aebd5f4ef5
22 changed files with 1746 additions and 67 deletions

View File

@@ -1,3 +1,4 @@
from .client import OpenTTDClient
from .decorators import exclude_call_check
from .client import OpenTTDClient, OpenTTDAdminClient
__all__ = ['OpenTTDClient']
__all__ = ['OpenTTDClient', 'OpenTTDAdminClient', 'exclude_call_check']

View File

@@ -4,8 +4,9 @@ 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
from openttd_protocol.wire.write import write_init, write_string, write_uint8, write_uint16, write_uint32, write_presend, SEND_TCP_MTU
from .protocol import PacketGameType, OpenTTDProtocol, PacketAdminType, OpenTTDAdminProtocol, NetworkAuthenticationMethod
from .decorators import exclude_call_check
class OpenTTDClient:
"""High-level OpenTTD client for easy integration."""
@@ -80,6 +81,7 @@ class OpenTTDClient:
# --- Internal Protocol Callbacks ---
@exclude_call_check
def connected(self, source): pass
async def receive_ServerGameInfo(self, source, **kwargs):
@@ -90,7 +92,31 @@ class OpenTTDClient:
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"}
error_names = {
0: "General",
1: "Desync",
2: "SavegameFailed",
3: "ConnectionLost",
4: "IllegalPacket",
5: "NewGRFMismatch",
6: "NotAuthorized",
7: "NotExpected",
8: "WrongRevision",
9: "NameInUse",
10: "WrongPassword",
11: "CompanyMismatch",
12: "Kicked",
13: "Cheater",
14: "ServerFull",
15: "TooManyCommands",
16: "TimeoutPassword",
17: "TimeoutComputer",
18: "TimeoutMap",
19: "TimeoutJoin",
20: "InvalidClientName",
21: "NotOnAllowList",
22: "NoAuthenticationMethodAvailable"
}
self.log.error(f"Server Error: {error_names.get(error_code, f'Code {error_code}')}")
await self.quit()
@@ -98,8 +124,9 @@ class OpenTTDClient:
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)
our_priv = monocypher.generate_key()
our_pub = monocypher.x25519_public_key(our_priv)
shared_secret = monocypher.x25519(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)
@@ -177,3 +204,230 @@ class OpenTTDClient:
async def receive_ServerBanned(self, source, **kwargs): pass
async def receive_ClientAck(self, source, **kwargs): pass
async def receive_ClientIdentify(self, source, **kwargs): pass
class OpenTTDAdminClient:
"""High-level OpenTTD Admin client."""
def __init__(self, host, port=3977, admin_name="GeminiAdmin"):
self.host = host
self.port = port
self.admin_name = admin_name
self.log = logging.getLogger(f"OTTDA-{admin_name}")
# State
self.encryption_enabled = False
self.joined = asyncio.Event()
self.shutdown_event = asyncio.Event()
# Internal crypto
self._admin_password = ""
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
self.on_console = None
self.on_gamescript = None
async def connect(self, admin_password="", secure=False):
"""Connect to the admin port and initiate handshake."""
self._admin_password = admin_password
self.log.info(f"Connecting to admin {self.host}:{self.port}...")
loop = asyncio.get_running_loop()
try:
self._transport, self._protocol = await loop.create_connection(
lambda: OpenTTDAdminProtocol(self), self.host, self.port
)
if secure:
d = write_init(PacketAdminType.AdminJoinSecure)
write_string(d, self.admin_name)
write_string(d, "1.0")
# Bitmask: 1 << NetworkAuthenticationMethod.X25519_PAKE
write_uint16(d, 1 << NetworkAuthenticationMethod.X25519_PAKE)
else:
d = write_init(PacketAdminType.AdminJoin)
write_string(d, self._admin_password)
write_string(d, self.admin_name)
write_string(d, "1.0")
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
except Exception as e:
self.log.error(f"Admin connection failed: {e}")
raise
def disconnect(self, source):
"""Library callback for when connection is lost."""
self.log.info("Admin disconnected.")
self.shutdown_event.set()
async def quit(self):
"""Gracefully disconnect from the server."""
if hasattr(self, '_protocol') and not self._transport.is_closing():
try:
d = write_init(PacketAdminType.AdminQuit)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
except Exception:
pass
self._transport.close()
self.shutdown_event.set()
async def send_rcon(self, command):
"""Send an RCON command."""
d = write_init(PacketAdminType.AdminRcon)
write_string(d, command)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def send_chat(self, message, action=1, dest_type=0, dest_id=0):
"""Send a chat message as admin. action=1 (CHAT), dest_type=0 (BROADCAST)."""
d = write_init(PacketAdminType.AdminChat)
write_uint8(d, action)
write_uint8(d, dest_type)
write_uint32(d, dest_id)
write_string(d, message)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def update_frequency(self, update_type, frequency):
"""Update the frequency of a certain piece of information."""
d = write_init(PacketAdminType.AdminUpdateFrequency)
write_uint16(d, update_type)
write_uint16(d, frequency)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def poll(self, update_type, data=0xFFFFFFFF):
"""Poll the server for certain updates."""
d = write_init(PacketAdminType.AdminPoll)
write_uint8(d, update_type)
write_uint32(d, data)
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
async def poll_clients(self, client_id=0xFFFFFFFF):
"""Poll for client information."""
from .protocol import AdminUpdateType
await self.poll(AdminUpdateType.ClientInfo, client_id)
async def poll_companies(self, company_id=0xFFFFFFFF):
"""Poll for company information."""
from .protocol import AdminUpdateType
await self.poll(AdminUpdateType.CompanyInfo, company_id)
async def poll_economy(self, company_id=0xFFFFFFFF):
"""Poll for company economy information."""
from .protocol import AdminUpdateType
await self.poll(AdminUpdateType.CompanyEconomy, company_id)
async def poll_stats(self, company_id=0xFFFFFFFF):
"""Poll for company statistics."""
from .protocol import AdminUpdateType
await self.poll(AdminUpdateType.CompanyStats, company_id)
async def send_gamescript(self, json_data):
"""Send a JSON string to the GameScript."""
import json
d = write_init(PacketAdminType.AdminGamescript)
write_string(d, json.dumps(json_data))
await self._protocol.send_packet(write_presend(d, SEND_TCP_MTU))
# --- Internal Protocol Callbacks ---
@exclude_call_check
def connected(self, source): pass
async def receive_ServerAuthRequest(self, source, auth_type, data):
if auth_type == 1: # X25519_PAKE
server_pub = bytes(data[:32])
nonce = bytes(data[32:56])
our_priv = monocypher.generate_key()
our_pub = monocypher.x25519_public_key(our_priv)
shared_secret = monocypher.x25519(our_priv, server_pub)
derived = monocypher.blake2b(shared_secret + server_pub + our_pub + self._admin_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(PacketAdminType.AdminAuthResponse)
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
self.log.info("Admin encryption enabled.")
async def receive_ServerProtocol(self, source, version, updates):
self.log.info(f"Admin Protocol version {version}")
async def receive_ServerWelcome(self, source, **kwargs):
self.log.info(f"Admin welcomed to {kwargs.get('server_name')}")
self.joined.set()
async def receive_ServerError(self, source, error_code):
self.log.error(f"Admin Server Error: {error_code}")
await self.quit()
async def receive_ServerChat(self, source, **kwargs):
if self.on_chat:
self.on_chat(**kwargs)
else:
self.log.info(f"ADMIN CHAT: <{kwargs.get('client_id')}> {kwargs.get('message')}")
async def receive_ServerConsole(self, source, **kwargs):
if self.on_console:
self.on_console(**kwargs)
else:
self.log.info(f"CONSOLE: [{kwargs.get('origin')}] {kwargs.get('text')}")
async def receive_ServerRcon(self, source, **kwargs):
self.log.info(f"RCON: {kwargs.get('text')}")
async def receive_ServerRconEnd(self, source, **kwargs):
self.log.info(f"RCON End: {kwargs.get('command')}")
async def receive_ServerClientJoin(self, source, **kwargs):
self.log.info(f"Admin: Client {kwargs.get('client_id')} joined.")
async def receive_ServerClientInfo(self, source, **kwargs):
self.log.info(f"Admin: Client Info: {kwargs.get('name')} (ID: {kwargs.get('client_id')}, IP: {kwargs.get('network_address')})")
async def receive_ServerClientUpdate(self, source, **kwargs):
self.log.info(f"Admin: Client {kwargs.get('client_id')} updated.")
async def receive_ServerClientQuit(self, source, **kwargs):
self.log.info(f"Admin: Client {kwargs.get('client_id')} quit.")
async def receive_ServerClientError(self, source, **kwargs):
self.log.info(f"Admin: Client {kwargs.get('client_id')} error: {kwargs.get('error_code')}")
async def receive_ServerCompanyNew(self, source, **kwargs):
self.log.info(f"Admin: Company {kwargs.get('company_id')} created.")
async def receive_ServerCompanyInfo(self, source, **kwargs):
self.log.info(f"Admin: Company Info: {kwargs.get('name')} (ID: {kwargs.get('company_id')})")
async def receive_ServerCompanyUpdate(self, source, **kwargs):
self.log.info(f"Admin: Company {kwargs.get('company_id')} updated.")
async def receive_ServerCompanyRemove(self, source, **kwargs):
self.log.info(f"Admin: Company {kwargs.get('company_id')} removed.")
async def receive_ServerCompanyEconomy(self, source, **kwargs):
self.log.info(f"Admin: Company {kwargs.get('company_id')} Economy: Money={kwargs.get('money')}, Loan={kwargs.get('loan')}")
async def receive_ServerCompanyStats(self, source, **kwargs):
self.log.info(f"Admin: Company {kwargs.get('company_id')} Stats: Vehicles={kwargs.get('vehicles')}, Stations={kwargs.get('stations')}")
async def receive_ServerGamescript(self, source, **kwargs):
if self.on_gamescript:
self.on_gamescript(kwargs.get('data'))
else:
self.log.info(f"GAMESCRIPT: {kwargs.get('data')}")
async def receive_ServerDate(self, source, **kwargs): pass
async def receive_ServerFull(self, source, **kwargs): await self.quit()
async def receive_ServerBanned(self, source, **kwargs): await self.quit()
async def receive_ServerShutdown(self, source, **kwargs): await self.quit()
async def receive_ServerNewGame(self, source, **kwargs): pass
async def receive_ServerPong(self, source, **kwargs):
self.log.info(f"Admin: Pong received: {kwargs.get('payload')}")

View File

@@ -0,0 +1,4 @@
def exclude_call_check(obj):
"""Decorator to mark a class or method to be excluded from public calls verification checks."""
obj.__exclude_call_check__ = True
return obj

View File

@@ -49,6 +49,80 @@ class PacketGameType(IntEnum):
ServerCompanyUpdate = 45
PACKET_END = 100
class PacketAdminType(IntEnum):
AdminJoin = 0
AdminQuit = 1
AdminUpdateFrequency = 2
AdminPoll = 3
AdminChat = 4
AdminRcon = 5
AdminGamescript = 6
AdminPing = 7
AdminExternalChat = 8
AdminJoinSecure = 9
AdminAuthResponse = 10
ServerFull = 100
ServerBanned = 101
ServerError = 102
ServerProtocol = 103
ServerWelcome = 104
ServerNewGame = 105
ServerShutdown = 106
ServerDate = 107
ServerClientJoin = 108
ServerClientInfo = 109
ServerClientUpdate = 110
ServerClientQuit = 111
ServerClientError = 112
ServerCompanyNew = 113
ServerCompanyInfo = 114
ServerCompanyUpdate = 115
ServerCompanyRemove = 116
ServerCompanyEconomy = 117
ServerCompanyStats = 118
ServerChat = 119
ServerRcon = 120
ServerConsole = 121
ServerCmdNames = 122
ServerCmdLoggingOld = 123
ServerGamescript = 124
ServerRconEnd = 125
ServerPong = 126
ServerCmdLogging = 127
ServerAuthRequest = 128
ServerEnableEncryption = 129
PACKET_END = 130
class AdminUpdateFrequency(IntEnum):
Poll = 1 << 0
Daily = 1 << 1
Weekly = 1 << 2
Monthly = 1 << 3
Quarterly = 1 << 4
Annually = 1 << 5
Automatic = 1 << 6
class AdminUpdateType(IntEnum):
Date = 0
ClientInfo = 1
CompanyInfo = 2
CompanyEconomy = 3
CompanyStats = 4
Chat = 5
Console = 6
CmdNames = 7
CmdLogging = 8
Gamescript = 9
End = 10
class NetworkAuthenticationMethod(IntEnum):
X25519_KeyExchangeOnly = 0
X25519_PAKE = 1
X25519_AuthorizedKey = 2
class OpenTTDProtocol(TCPProtocol):
"""Low-level OpenTTD TCP protocol handler with encryption support."""
PacketType = PacketGameType
@@ -69,9 +143,6 @@ class OpenTTDProtocol(TCPProtocol):
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, {}
@@ -84,7 +155,6 @@ class OpenTTDProtocol(TCPProtocol):
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
@@ -171,3 +241,227 @@ class OpenTTDProtocol(TCPProtocol):
return {"frame": f, "token": t}
@staticmethod
def receive_ClientIdentify(source, data): return {}
class OpenTTDAdminProtocol(OpenTTDProtocol):
"""Low-level OpenTTD Admin TCP protocol handler."""
PacketType = PacketAdminType
PACKET_END = PacketAdminType.PACKET_END
@staticmethod
def receive_ServerProtocol(source, data):
v, data = read_uint8(data)
updates = {}
while True:
has_more, data = read_uint8(data)
if not has_more: break
ut, data = read_uint16(data)
freqs, data = read_uint16(data)
updates[ut] = freqs
return {"version": v, "updates": updates}
@staticmethod
def receive_ServerWelcome(source, data):
name, data = read_string(data)
ver, data = read_string(data)
dedi, data = read_uint8(data)
map_name, data = read_string(data)
seed, data = read_uint32(data)
land, data = read_uint8(data)
date, data = read_uint32(data)
width, data = read_uint16(data)
height, _ = read_uint16(data)
return {
"server_name": name, "openttd_version": ver, "dedicated": bool(dedi),
"map_name": map_name, "generation_seed": seed, "landscape": land,
"start_date": date, "map_width": width, "map_height": height
}
@staticmethod
def receive_ServerDate(source, data):
d, _ = read_uint32(data)
return {"date": d}
@staticmethod
def receive_ServerChat(source, data):
action, data = read_uint8(data)
dest_type, data = read_uint8(data)
cid, data = read_uint32(data)
msg, data = read_string(data)
money = 0
if len(data) >= 8:
import struct
money = struct.unpack("<Q", data[:8])[0]
return {"action": action, "dest_type": dest_type, "client_id": cid, "message": msg, "money": money}
@staticmethod
def receive_ServerConsole(source, data):
origin, data = read_string(data)
text, _ = read_string(data)
return {"origin": origin, "text": text}
@staticmethod
def receive_ServerRcon(source, data):
color, data = read_uint16(data)
text, _ = read_string(data)
return {"color": color, "text": text}
@staticmethod
def receive_ServerRconEnd(source, data):
cmd, _ = read_string(data)
return {"command": cmd}
@staticmethod
def receive_ServerAuthRequest(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_ServerError(source, data):
ec, _ = read_uint8(data)
return {"error_code": ec}
@staticmethod
def receive_ServerFull(source, data): return {}
@staticmethod
def receive_ServerBanned(source, data): return {}
@staticmethod
def receive_ServerShutdown(source, data): return {}
@staticmethod
def receive_ServerNewGame(source, data): return {}
@staticmethod
def receive_ServerClientJoin(source, data):
cid, _ = read_uint32(data)
return {"client_id": cid}
@staticmethod
def receive_ServerClientInfo(source, data):
cid, data = read_uint32(data)
addr, data = read_string(data)
name, data = read_string(data)
lang, data = read_uint8(data)
date, data = read_uint32(data)
playas, _ = read_uint8(data)
return {
"client_id": cid, "network_address": addr, "name": name,
"language": lang, "join_date": date, "play_as": playas
}
@staticmethod
def receive_ServerClientUpdate(source, data):
cid, data = read_uint32(data)
name, data = read_string(data)
playas, _ = read_uint8(data)
return {"client_id": cid, "name": name, "play_as": playas}
@staticmethod
def receive_ServerClientQuit(source, data):
cid, _ = read_uint32(data)
return {"client_id": cid}
@staticmethod
def receive_ServerClientError(source, data):
cid, data = read_uint32(data)
error, _ = read_uint8(data)
return {"client_id": cid, "error_code": error}
@staticmethod
def receive_ServerCompanyNew(source, data):
cid, _ = read_uint8(data)
return {"company_id": cid}
@staticmethod
def receive_ServerCompanyInfo(source, data):
cid, data = read_uint8(data)
name, data = read_string(data)
manager, data = read_string(data)
color, data = read_uint8(data)
protected, data = read_uint8(data)
year, data = read_uint32(data)
is_ai, _ = read_uint8(data)
return {
"company_id": cid, "name": name, "manager_name": manager,
"color": color, "password_protected": bool(protected),
"inaugurated_year": year, "is_ai": bool(is_ai)
}
@staticmethod
def receive_ServerCompanyUpdate(source, data):
cid, data = read_uint8(data)
name, data = read_string(data)
manager, data = read_string(data)
color, data = read_uint8(data)
protected, data = read_uint8(data)
bankrupt, data = read_uint8(data)
s1, data = read_uint8(data)
s2, data = read_uint8(data)
s3, data = read_uint8(data)
s4, _ = read_uint8(data)
return {
"company_id": cid, "name": name, "manager_name": manager,
"color": color, "password_protected": bool(protected),
"quarters_of_bankruptcy": bankrupt, "share_owners": [s1, s2, s3, s4]
}
@staticmethod
def receive_ServerCompanyRemove(source, data):
cid, data = read_uint8(data)
reason, _ = read_uint8(data)
return {"company_id": cid, "reason": reason}
@staticmethod
def receive_ServerCompanyEconomy(source, data):
import struct
cid, data = read_uint8(data)
money = struct.unpack("<Q", data[:8])[0]; data = data[8:]
loan = struct.unpack("<Q", data[:8])[0]; data = data[8:]
income = struct.unpack("<q", data[:8])[0]; data = data[8:]
delivered, data = read_uint16(data)
val_lq = struct.unpack("<Q", data[:8])[0]; data = data[8:]
perf_lq, data = read_uint16(data)
del_lq, data = read_uint16(data)
val_pq = struct.unpack("<Q", data[:8])[0]; data = data[8:]
perf_pq, data = read_uint16(data)
del_pq, _ = read_uint16(data)
return {
"company_id": cid, "money": money, "loan": loan, "income": income,
"delivered_cargo": delivered, "value_last_quarter": val_lq,
"performance_last_quarter": perf_lq, "delivered_cargo_last_quarter": del_lq,
"value_previous_quarter": val_pq, "performance_previous_quarter": perf_pq,
"delivered_cargo_previous_quarter": del_pq
}
@staticmethod
def receive_ServerCompanyStats(source, data):
cid, data = read_uint8(data)
trains, data = read_uint16(data)
lorries, data = read_uint16(data)
buses, data = read_uint16(data)
planes, data = read_uint16(data)
ships, data = read_uint16(data)
t_stations, data = read_uint16(data)
l_stations, data = read_uint16(data)
b_stops, data = read_uint16(data)
airports, data = read_uint16(data)
harbours, _ = read_uint16(data)
return {
"company_id": cid,
"vehicles": {"trains": trains, "lorries": lorries, "buses": buses, "planes": planes, "ships": ships},
"stations": {"train": t_stations, "lorry": l_stations, "bus": b_stops, "airport": airports, "harbour": harbours}
}
@staticmethod
def receive_ServerGamescript(source, data):
json_str, _ = read_string(data)
import json
try:
return {"data": json.loads(json_str)}
except:
return {"raw_data": json_str}
@staticmethod
def receive_ServerPong(source, data):
payload, _ = read_uint32(data)
return {"payload": payload}