Repository navigation
Expand file tree
/
Copy pathgame_bot.py
More file actions
398 lines (349 loc) · 14.6 KB
/
Copy pathgame_bot.py
File metadata and controls
398 lines (349 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
#!/usr/bin/env python3
"""Finite signed game pilot and optional local webhook receiver. See docs/ai-game.md."""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import os
import secrets
import sqlite3
import stat
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
EVENTS = ('game.joined', 'game.left', 'game.region', 'game.collect', 'game.hit')
def seconds(value):
number = float(value)
if not math.isfinite(number) or not 1 <= number <= 300:
raise argparse.ArgumentTypeError('seconds must be between 1 and 300')
return number
def control(value):
number = float(value)
if not math.isfinite(number) or not -1 <= number <= 1:
raise argparse.ArgumentTypeError('control must be between -1 and 1')
return number
def angle(value):
number = float(value)
if not math.isfinite(number) or not -math.pi <= number <= math.pi:
raise argparse.ArgumentTypeError('angle must be finite radians between -pi and pi')
return number
def parser():
root = argparse.ArgumentParser(description=__doc__)
root.add_argument('--server', help='Existing configured service origin')
root.add_argument('--account', help='Existing local account, for example lightjunction')
root.add_argument('--profile', help='Existing MSG client profile')
root.add_argument('--config-dir', type=Path, help='Existing isolated client directory')
root.add_argument(
'--key', type=Path, help='Existing owned 0600 raw 32-byte Ed25519 private key'
)
root.add_argument('--certificate', action='append', help='Certificate matching the chosen key')
commands = root.add_subparsers(dest='command', required=True)
pilot = commands.add_parser('pilot', help='Sign a short-lived ticket and fly at most 10 Hz')
pilot.add_argument(
'--guest', action='store_true', help='Public guest; no identity or key reads'
)
pilot.add_argument(
'--seconds', type=seconds, default=30, help='Total budget, including join, 1..300'
)
for name in ('throttle', 'strafe', 'lift'):
pilot.add_argument('--' + name, type=control, default=0.25 if name == 'throttle' else 0)
pilot.add_argument('--yaw', type=angle, default=0, help='Desired yaw in radians')
pilot.add_argument(
'--pitch', type=angle, default=0, help='Desired pitch in radians, -pi/2..pi/2'
)
setting = commands.add_parser(
'webhook-set', help='Explicitly rotate the account shared endpoint'
)
setting.add_argument('--url', required=True, help='Public HTTPS endpoint on port 443')
setting.add_argument(
'--secret-file', type=Path, required=True, help='Owned 0600 raw 32..64 bytes'
)
subscribe = commands.add_parser(
'subscribe', help='Enable owner game events on the current endpoint'
)
subscribe.add_argument('--events', nargs='+', choices=EVENTS, default=list(EVENTS))
commands.add_parser('unsubscribe', help='Disable this account game subscription')
commands.add_parser('status', help='Read game subscription status')
receive = commands.add_parser('receive', help='Local HMAC receiver behind your HTTPS proxy')
receive.add_argument(
'--secret-file', type=Path, required=True, help='Owned 0600 raw 32..64 bytes'
)
receive.add_argument(
'--replay-db', type=Path, required=True, help='Ledger in an owned 0700 directory'
)
receive.add_argument('--host', choices=('127.0.0.1', '::1'), default='127.0.0.1')
receive.add_argument('--port', type=int, default=8787)
receive.add_argument('--seconds', type=seconds, default=300)
return root
def secret_file(path):
from msg.core.errors import require
descriptor = os.open(path.expanduser(), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(descriptor)
require(
stat.S_ISREG(info.st_mode)
and info.st_uid == os.geteuid()
and info.st_nlink == 1
and info.st_mode & 0o077 == 0,
'unsafe_game_secret_file',
)
value = os.read(descriptor, 65)
require(32 <= len(value) <= 64 and info.st_size == len(value), 'invalid_webhook_secret')
return value
finally:
os.close(descriptor)
@asynccontextmanager
async def signed_client(args):
from msg.client import ClientState, MsgClient, private_identity_key
from msg.core.errors import require
from msg.transports.client import HTTPTransport
state = ClientState(
args.config_dir, server=args.server, account=args.account, profile=args.profile
)
signer = private_identity_key(args.key.expanduser()) if args.key else state.signer
require(
signer is not None and state.subject is not None, 'configured_signing_identity_required'
)
certificates = tuple(args.certificate) if args.certificate else state.certificates
transport = HTTPTransport(state.server)
client = MsgClient(state, transport)
try:
async def call(operation, arguments):
# Explicit signer bypasses both saved tokens and automatic OAuth refresh.
return client.checked(
await client.call(
operation,
arguments,
signer=signer,
certificates=certificates,
contract_version=1,
)
)
yield state, call
finally:
await transport.close()
async def pilot_controls(websocket, hello, args):
"""Use an existing joined aiohttp WS; args supplies seconds and the five controls."""
import aiohttp
from msg.core.codec import loads
from msg.core.errors import Failure, require
require(hello.get('type') == 'hello' and hello.get('v') == 1, 'invalid_game_hello')
require(1 <= args.seconds <= 300, 'invalid_game_duration')
print(json.dumps({'joined': hello['self']['id'], 'region': hello['region']}), flush=True)
async def drain():
async for frame in websocket:
if frame.type != aiohttp.WSMsgType.TEXT:
raise Failure('game_socket_closed')
value = loads(frame.data)
require(value.get('type') == 'snapshot', 'invalid_game_snapshot')
# A policy can inspect this authorized snapshot, then choose controls.
# Keep poses and resource IDs out of outgoing control packets.
raise Failure('game_socket_closed')
reader = asyncio.create_task(drain())
try:
async with asyncio.timeout(args.seconds):
sequence = 0
while True:
if reader.done():
await reader
await websocket.send_json({
'v': 1,
'type': 'input',
'seq': sequence,
'throttle': args.throttle,
'strafe': args.strafe,
'lift': args.lift,
'yaw': args.yaw,
'pitch': args.pitch,
'actions': [],
})
sequence += 1
await asyncio.sleep(0.1)
except TimeoutError:
pass
finally:
reader.cancel()
await asyncio.gather(reader, return_exceptions=True)
@asynccontextmanager
async def pilot_identity(args):
if args.guest:
yield args.server or 'https://msg.lmm.best', None
else:
async with signed_client(args) as (state, call):
yield state.server, call
async def pilot(args):
import aiohttp
from msg.core.codec import b64, loads
from msg.core.errors import Failure, require
require(-math.pi / 2 <= args.pitch <= math.pi / 2, 'invalid_pitch')
joined = False
try:
async with asyncio.timeout(args.seconds), pilot_identity(args) as (server, call):
packet = {'v': 1, 'type': 'join'}
if call is not None:
# Only this process keeps the nonce; receipts contain the public ticket ID.
nonce = b64(secrets.token_bytes(32))
ticket = (await call('communication.game_join_ticket', {'nonce': nonce})).data
require(
isinstance(ticket.get('ticket_id'), str)
and ticket.get('websocket') == '/_flight'
and ticket.get('protocol') == 1,
'invalid_game_ticket',
)
packet['ticket'] = {'id': ticket['ticket_id'], 'nonce': nonce}
parts = urlsplit(server)
require(
parts.scheme in {'http', 'https'}
and bool(parts.netloc)
and parts.username is None
and parts.password is None
and not parts.query
and not parts.fragment,
'invalid_game_server',
)
url = urlunsplit((
'wss' if parts.scheme == 'https' else 'ws',
parts.netloc,
'/_flight',
'',
'',
))
async with aiohttp.ClientSession(
cookie_jar=aiohttp.DummyCookieJar(),
trust_env=False,
timeout=aiohttp.ClientTimeout(total=10),
) as http:
async with http.ws_connect(
url,
headers={'Origin': server},
max_msg_size=192 * 1024,
) as websocket:
await websocket.send_json(packet)
frame = await websocket.receive()
require(frame.type == aiohttp.WSMsgType.TEXT, 'game_socket_closed')
hello = loads(frame.data)
require(
hello.get('type') == 'hello' and hello.get('v') == 1, 'invalid_game_hello'
)
joined = True
await pilot_controls(websocket, hello, args)
except TimeoutError:
if not joined:
raise Failure('game_join_timeout') from None
print(json.dumps({'status': 'finished', 'reason': 'time_limit'}), flush=True)
def ledger(path):
from msg.core.errors import require
path = path.expanduser().absolute()
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
info = path.parent.lstat()
require(
stat.S_ISDIR(info.st_mode) and info.st_uid == os.geteuid() and info.st_mode & 0o077 == 0,
'unsafe_game_ledger_directory',
)
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
try:
info = os.fstat(descriptor)
require(
stat.S_ISREG(info.st_mode)
and info.st_uid == os.geteuid()
and info.st_mode & 0o077 == 0
and info.st_nlink == 1,
'unsafe_game_ledger',
)
finally:
os.close(descriptor)
connection = sqlite3.connect(path)
connection.execute(
'CREATE TABLE IF NOT EXISTS deliveries (id TEXT PRIMARY KEY, type TEXT NOT NULL)'
)
connection.commit()
return connection
async def receive(args):
from aiohttp import web
from msg.core.codec import loads
from msg.core.errors import Failure, require
from msg.workers.game_webhook import bounded_game_event
from msg.workers.webhook import verify_delivery
require(1 <= args.port <= 65535, 'invalid_receiver_port')
secret = secret_file(args.secret_file)
database = ledger(args.replay_db)
async def hook(request):
body = await request.read()
try:
identifier = verify_delivery(request.headers, body, secret, now=datetime.now(UTC))
envelope = loads(body)
event = bounded_game_event(envelope.get('event'))
require(
event['id'] == envelope['event_id'] and event['type'] == envelope.get('type'),
'invalid_game_event',
)
except Failure:
return web.Response(status=400)
with database:
if database.execute('SELECT 1 FROM deliveries WHERE id=?', (identifier,)).fetchone():
return web.Response(status=204)
if database.execute('SELECT COUNT(*) FROM deliveries').fetchone()[0] >= 100_000:
return web.Response(status=503)
database.execute('INSERT INTO deliveries VALUES (?,?)', (identifier, event['type']))
# The ledger is durable; stdout is only a demo. Real business effects need the same transaction.
print(json.dumps({'delivery_id': identifier, 'type': event['type']}), flush=True)
return web.Response(status=204)
app = web.Application(client_max_size=4096)
app.router.add_post('/hook', hook)
runner = web.AppRunner(app, access_log=None)
try:
await runner.setup()
await web.TCPSite(runner, args.host, args.port).start()
print(
json.dumps({
'receiver': f'http://{args.host}:{args.port}/hook',
'seconds': args.seconds,
}),
flush=True,
)
await asyncio.sleep(args.seconds)
finally:
await runner.cleanup()
database.close()
async def run(args):
if args.command == 'receive':
return await receive(args)
if args.command == 'pilot':
return await pilot(args)
from msg.core.codec import b64, wire
from msg.workers.webhook import validate_endpoint
async with signed_client(args) as (_, call):
if args.command == 'webhook-set':
validate_endpoint(args.url)
operation = 'communication.webhook_set'
arguments = {'url': args.url, 'secret': b64(secret_file(args.secret_file))}
elif args.command == 'subscribe':
operation = 'communication.game_webhook_subscribe'
arguments = {'events': sorted(set(args.events))}
else:
operation = 'communication.game_webhook_' + args.command
arguments = {}
result = await call(operation, arguments)
print(json.dumps(wire(result.data), ensure_ascii=False), flush=True)
def main():
args = (
parser().parse_args()
) # --help exits before any identity, secret or network initialization.
from aiohttp import ClientError
from httpx import HTTPError
from msg.core.errors import Failure
try:
asyncio.run(run(args))
except KeyboardInterrupt:
return 130
except Failure as exc:
print(json.dumps({'error': exc.code}))
return 1
except OSError, TimeoutError, sqlite3.Error, ClientError, HTTPError:
print(json.dumps({'error': 'game_example_unavailable'}))
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())