multiplayer 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
multiplayer/server.py ADDED
@@ -0,0 +1,676 @@
1
+ """
2
+ This module provides the server-side implementation for networked multiplayer games.
3
+ """
4
+ import socket
5
+ import json
6
+ import threading
7
+ import uuid
8
+ import struct
9
+ import ssl
10
+ import tempfile
11
+ import os
12
+ import logging
13
+ from multiprocessing import get_context
14
+ from datetime import datetime, timedelta, timezone
15
+ from cryptography import x509
16
+ from cryptography.x509.oid import NameOID
17
+ from cryptography.hazmat.primitives import hashes
18
+ from cryptography.hazmat.primitives.asymmetric import rsa
19
+ from cryptography.hazmat.primitives import serialization
20
+ import enum
21
+
22
+ from .game import Game, Player, Observer, GameState
23
+ from .exceptions import GameLogicError, PlayerLimitReachedError, ObserverLimitReachedError, AuthenticationError
24
+
25
+ # Custom JSON Encoder to handle enums
26
+ class EnumEncoder(json.JSONEncoder):
27
+ def default(self, obj):
28
+ if isinstance(obj, enum.Enum):
29
+ return obj.value
30
+ return super().default(obj)
31
+
32
+ # Constants for network discovery
33
+ MULTICAST_GROUP = '224.1.1.1'
34
+ DISCOVERY_PORT = 5007
35
+ DISCOVERY_MESSAGE = b'multiplayer_game_discovery_request'
36
+ RESPONSE_MESSAGE_FORMAT = b'!15sH' # 15-char IP, unsigned short port
37
+
38
+ def _generate_self_signed_cert(domain="localhost"):
39
+ """Generates a temporary self-signed certificate and key."""
40
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
41
+ subject = issuer = x509.Name([
42
+ x509.NameAttribute(NameOID.COMMON_NAME, domain),
43
+ ])
44
+ cert = x509.CertificateBuilder().subject_name(
45
+ subject
46
+ ).issuer_name(
47
+ issuer
48
+ ).public_key(
49
+ key.public_key()
50
+ ).serial_number(
51
+ x509.random_serial_number()
52
+ ).not_valid_before(
53
+ datetime.now(timezone.utc)
54
+ ).not_valid_after(
55
+ datetime.now(timezone.utc) + timedelta(days=365)
56
+ ).add_extension(
57
+ x509.SubjectAlternativeName([x509.DNSName(domain)]),
58
+ critical=False,
59
+ ).sign(key, hashes.SHA256())
60
+ key_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pem")
61
+ cert_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pem")
62
+ key_file.write(key.private_bytes(
63
+ encoding=serialization.Encoding.PEM,
64
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
65
+ encryption_algorithm=serialization.NoEncryption(),
66
+ ))
67
+ cert_file.write(cert.public_bytes(serialization.Encoding.PEM))
68
+ key_file.close()
69
+ cert_file.close()
70
+ return cert_file.name, key_file.name
71
+
72
+ def get_cert_expiration(cert_path):
73
+ """Returns the expiration date of a PEM certificate."""
74
+ try:
75
+ with open(cert_path, "rb") as f:
76
+ cert_data = f.read()
77
+ cert = x509.load_pem_x509_certificate(cert_data)
78
+ return cert.not_valid_after_utc.isoformat()
79
+ except Exception as e:
80
+ return f"Error reading certificate: {e}"
81
+
82
+ def _run_server_process(host, port, password, admin_password, use_tls, certfile, keyfile, logging_host=None, logging_port=None, logger_name="GameServer", name=None):
83
+ """The main server loop that listens for and handles connections."""
84
+ logger = logging.getLogger(logger_name)
85
+ logger.setLevel(logging.INFO)
86
+ if logging_host and logging_port:
87
+ from logging.handlers import SocketHandler
88
+ # Remove existing SocketHandlers if any
89
+ for h in logger.handlers[:]:
90
+ if isinstance(h, SocketHandler):
91
+ logger.removeHandler(h)
92
+ handler = SocketHandler(logging_host, logging_port)
93
+ logger.addHandler(handler)
94
+ logger.info(f"Logging configured to send to {logging_host}:{logging_port}")
95
+
96
+ server_start_msg = f"Starting server process on {host}:{port}"
97
+ if name:
98
+ server_start_msg += f" (Name: {name})"
99
+ logger.info(server_start_msg)
100
+ games = {}
101
+ games_lock = threading.Lock()
102
+ context = None
103
+ if use_tls:
104
+ context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
105
+ context.minimum_version = ssl.TLSVersion.TLSv1_3
106
+ context.load_cert_chain(certfile=certfile, keyfile=keyfile)
107
+ bindsocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
108
+ bindsocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
109
+ bindsocket.bind((host, port))
110
+ bindsocket.listen()
111
+ groups = {} # Store GameGroup objects
112
+ server_passwords = {'server': password, 'admin': admin_password}
113
+ try:
114
+ bindsocket.settimeout(1.0)
115
+ while True:
116
+ try:
117
+ newsocket, fromaddr = bindsocket.accept()
118
+ except socket.timeout:
119
+ continue
120
+ try:
121
+ conn = context.wrap_socket(newsocket, server_side=True) if use_tls else newsocket
122
+ thread = threading.Thread(target=_handle_client, args=(conn, fromaddr, games, groups, games_lock, server_passwords, logger_name, name, use_tls, certfile))
123
+ thread.daemon = True
124
+ thread.start()
125
+ except (ssl.SSLError, OSError) as e:
126
+ print(f"Failed to wrap socket or start thread: {e}")
127
+ newsocket.close()
128
+ finally:
129
+ bindsocket.close()
130
+ # Clean up temporary files if they were created (indicated by "tmp" in filename or being specifically tracked)
131
+ # Note: self._temp_certs from GameServer is not available here, so we rely on path indicators or naming.
132
+ if use_tls and certfile and ("tmp" in certfile.lower() or "multiplayer_fullchain" in certfile.lower()):
133
+ try:
134
+ if os.path.exists(certfile):
135
+ os.remove(certfile)
136
+ # Only remove keyfile if it's also a temp file (like in self-signed case)
137
+ if keyfile and "tmp" in keyfile.lower() and os.path.exists(keyfile):
138
+ os.remove(keyfile)
139
+ except Exception:
140
+ pass
141
+
142
+ def _handle_client(conn, addr, games, groups, lock, server_passwords, logger_name="GameServer", server_name=None, use_tls=False, certfile=None):
143
+ """Handles a single client connection."""
144
+ logger = logging.getLogger(logger_name)
145
+ logger.info(f"Connected by {addr}")
146
+ try:
147
+ with conn:
148
+ data = conn.recv(1024)
149
+ if not data:
150
+ return
151
+ try:
152
+ command = json.loads(data.decode('utf-8'))
153
+ client_password = command.get('password')
154
+ action = command.get('action')
155
+ params = command.get('params', {})
156
+
157
+ server_password = server_passwords.get('server')
158
+ admin_password = server_passwords.get('admin')
159
+
160
+ # Check if it's an admin action
161
+ is_server_admin_action = action in ['stop_server', 'restart_server', 'get_server_info', 'set_logging_config', 'set_logging_enabled', 'list_all_players', 'get_cert_expiration', 'set_server_password', 'set_admin_password', 'create_group', 'remove_group', 'list_groups']
162
+ is_group_admin_action = action in ['list_group_games', 'kick_player', 'kick_observer', 'set_group_admin_password', 'get_group_info']
163
+
164
+ # If it's a kick action, it could be server admin OR group admin
165
+ # If group_id is provided, we check group admin rights.
166
+ # If not, we check server admin rights.
167
+ group_id = params.get('group_id')
168
+
169
+ if is_server_admin_action:
170
+ if admin_password is None:
171
+ raise AuthenticationError("Admin actions are disabled on this server")
172
+ if client_password != admin_password:
173
+ raise AuthenticationError("Invalid admin password")
174
+ elif is_group_admin_action and group_id:
175
+ with lock:
176
+ group = None
177
+ for g in groups.values():
178
+ if g.ID == group_id:
179
+ group = g
180
+ break
181
+ if not group:
182
+ raise GameLogicError(f"Group with ID '{group_id}' not found")
183
+ if group.admin_password is None:
184
+ raise AuthenticationError(f"Group admin actions are disabled for group ID '{group_id}'")
185
+ if client_password != group.admin_password:
186
+ # Allow server admin to also act as group admin
187
+ if admin_password is None or client_password != admin_password:
188
+ raise AuthenticationError("Invalid group admin password")
189
+ elif is_group_admin_action: # Kick without group_name -> must be server admin
190
+ if admin_password is None:
191
+ raise AuthenticationError("Admin actions are disabled on this server")
192
+ if client_password != admin_password:
193
+ raise AuthenticationError("Invalid admin password")
194
+ elif server_password is not None and client_password != server_password:
195
+ raise AuthenticationError("Invalid server password")
196
+
197
+ with lock:
198
+ response = _execute_command(games, groups, action, params, server_name=server_name, use_tls=use_tls, certfile=certfile, server_passwords=server_passwords)
199
+ conn.sendall(json.dumps(response, cls=EnumEncoder).encode('utf-8'))
200
+ except (json.JSONDecodeError, TypeError, AuthenticationError, GameLogicError) as e:
201
+ error_response = {'status': 'error', 'type': type(e).__name__, 'message': str(e)}
202
+ conn.sendall(json.dumps(error_response, cls=EnumEncoder).encode('utf-8'))
203
+ finally:
204
+ logger.info(f"Disconnected from {addr}")
205
+
206
+ def _execute_command(games, groups, action, params, server_name=None, use_tls=False, certfile=None, server_passwords=None):
207
+ """Executes a command on the game objects and returns a response."""
208
+ from .game import GameGroup
209
+ try:
210
+ # Server-level actions
211
+ if action == 'create_game':
212
+ game_id = str(uuid.uuid4())
213
+ # Ensure name is in params so it's part of attributes
214
+ game = Game(**params)
215
+ games[game_id] = game
216
+
217
+ # If group_id is provided, add game to group
218
+ group_id = params.get('group_id')
219
+ if group_id:
220
+ group = None
221
+ for g in groups.values():
222
+ if g.ID == group_id:
223
+ group = g
224
+ break
225
+ if group:
226
+ group.add_game(game)
227
+
228
+ return {'status': 'success', 'data': {'game_id': game_id, 'name': game.name}}
229
+
230
+ elif action == 'create_group':
231
+ group_name = params.get('name')
232
+ if not group_name:
233
+ return {'status': 'error', 'message': 'Missing group name'}
234
+ if group_name in groups:
235
+ return {'status': 'error', 'message': f'Group {group_name} already exists'}
236
+ admin_password = params.get('admin_password')
237
+ group = GameGroup(group_name, admin_password=admin_password, **params.get('attributes', {}))
238
+ groups[group_name] = group
239
+ return {'status': 'success', 'data': {'group_id': group.ID, 'name': group.name}}
240
+
241
+ elif action == 'remove_group':
242
+ group_id = params.get('group_id')
243
+ if not group_id:
244
+ return {'status': 'error', 'message': 'Missing group ID'}
245
+
246
+ target_name = None
247
+ for name, group in groups.items():
248
+ if group.ID == group_id:
249
+ target_name = name
250
+ break
251
+
252
+ if not target_name:
253
+ return {'status': 'error', 'message': f'Group with ID {group_id} not found'}
254
+
255
+ del groups[target_name]
256
+ return {'status': 'success', 'message': f'Group {target_name} removed'}
257
+
258
+ elif action == 'list_groups':
259
+ group_list = {}
260
+ for name, group in groups.items():
261
+ group_list[group.ID] = {
262
+ 'name': group.name,
263
+ 'attributes': group.attributes,
264
+ 'games_count': len(group.games)
265
+ }
266
+ return {'status': 'success', 'data': group_list}
267
+
268
+ elif action == 'set_server_password':
269
+ new_password = params.get('new_password')
270
+ if server_passwords is not None:
271
+ server_passwords['server'] = new_password
272
+ return {'status': 'success', 'message': 'Server password updated'}
273
+ return {'status': 'error', 'message': 'Server passwords dictionary not available'}
274
+
275
+ elif action == 'set_admin_password':
276
+ new_password = params.get('new_password')
277
+ if server_passwords is not None:
278
+ server_passwords['admin'] = new_password
279
+ return {'status': 'success', 'message': 'Admin password updated'}
280
+ return {'status': 'error', 'message': 'Server passwords dictionary not available'}
281
+
282
+ elif action == 'set_group_admin_password':
283
+ group_id = params.get('group_id')
284
+ new_password = params.get('new_password')
285
+ group = None
286
+ for g in groups.values():
287
+ if g.ID == group_id:
288
+ group = g
289
+ break
290
+ if not group:
291
+ return {'status': 'error', 'message': f'Group with ID {group_id} not found'}
292
+ group.admin_password = new_password
293
+ return {'status': 'success', 'message': f'Admin password for group {group.name} updated'}
294
+
295
+ elif action == 'list_group_games':
296
+ group_id = params.get('group_id')
297
+ group = None
298
+ for g in groups.values():
299
+ if g.ID == group_id:
300
+ group = g
301
+ break
302
+ if not group:
303
+ return {'status': 'error', 'message': f'Group with ID {group_id} not found'}
304
+ # We need to find the GIDs for the games in the group
305
+ group_games = {}
306
+ for gid, g in games.items():
307
+ if g in group.games and g.state != GameState.FINISHED:
308
+ attrs = g.attributes.copy()
309
+ attrs['name'] = g.name
310
+ group_games[gid] = attrs
311
+ return {'status': 'success', 'data': group_games}
312
+
313
+ elif action == 'get_group_info':
314
+ group_id = params.get('group_id')
315
+ group = None
316
+ for g in groups.values():
317
+ if g.ID == group_id:
318
+ group = g
319
+ break
320
+ if not group:
321
+ return {'status': 'error', 'message': f'Group with ID {group_id} not found'}
322
+ return {
323
+ 'status': 'success',
324
+ 'data': {
325
+ 'name': group.name,
326
+ 'attributes': group.attributes,
327
+ 'games_count': len(group.games)
328
+ }
329
+ }
330
+
331
+ elif action == 'list_games':
332
+ game_list = {}
333
+ for gid, g in games.items():
334
+ if g.state != GameState.FINISHED:
335
+ attrs = g.attributes.copy()
336
+ attrs['name'] = g.name
337
+ game_list[gid] = attrs
338
+ return {'status': 'success', 'data': game_list}
339
+
340
+ elif action == 'stop_server':
341
+ result = {'status': 'success', 'message': 'Server stopping...'}
342
+ def delayed_exit():
343
+ import time
344
+ time.sleep(0.5)
345
+ os._exit(0)
346
+ threading.Thread(target=delayed_exit).start()
347
+ return result
348
+
349
+ elif action == 'restart_server':
350
+ result = {'status': 'success', 'message': 'Server restarting...'}
351
+ def delayed_restart():
352
+ import time
353
+ time.sleep(0.5)
354
+ games.clear()
355
+ groups.clear()
356
+ threading.Thread(target=delayed_restart).start()
357
+ return result
358
+
359
+ elif action == 'get_server_info':
360
+ return {'status': 'success', 'data': {
361
+ 'server_name': server_name,
362
+ 'games_count': len(games),
363
+ 'active_games': [gid for gid, g in games.items() if g.state != GameState.FINISHED]
364
+ }}
365
+
366
+ elif action == 'set_logging_config':
367
+ logging_host = params.get('host')
368
+ logging_port = params.get('port')
369
+ if logging_host and logging_port:
370
+ from logging.handlers import SocketHandler
371
+ logger = logging.getLogger("GameServer")
372
+ # Remove existing SocketHandlers if any to avoid duplicates
373
+ for h in logger.handlers[:]:
374
+ if isinstance(h, SocketHandler):
375
+ logger.removeHandler(h)
376
+
377
+ handler = SocketHandler(logging_host, logging_port)
378
+ logger.addHandler(handler)
379
+ logger.info(f"Logging reconfigured to send to {logging_host}:{logging_port}")
380
+ return {'status': 'success'}
381
+ else:
382
+ return {'status': 'error', 'message': 'Missing host or port'}
383
+
384
+ elif action == 'set_logging_enabled':
385
+ enabled = params.get('enabled', True)
386
+ logger = logging.getLogger("GameServer")
387
+ if enabled:
388
+ logger.setLevel(logging.INFO)
389
+ logger.info("Logging enabled")
390
+ else:
391
+ logger.info("Logging disabled")
392
+ logger.setLevel(logging.CRITICAL + 1) # Effectively disables all logging
393
+ return {'status': 'success'}
394
+
395
+ elif action == 'list_all_players':
396
+ all_players = []
397
+ for gid, game in games.items():
398
+ game_name = game.name or 'Unknown'
399
+ for player in game.players:
400
+ all_players.append({
401
+ 'name': player.name,
402
+ 'attributes': player.attributes,
403
+ 'game_id': gid,
404
+ 'game_name': game_name
405
+ })
406
+ return {'status': 'success', 'data': all_players}
407
+
408
+ elif action == 'get_cert_expiration':
409
+ if not use_tls or not certfile:
410
+ return {'status': 'error', 'message': 'TLS is not enabled or no certificate provided'}
411
+ expiration = get_cert_expiration(certfile)
412
+ return {'status': 'success', 'expiration': expiration}
413
+
414
+ # Game-specific actions
415
+ game_id = params.get('game_id')
416
+ if not game_id or game_id not in games:
417
+ return {'status': 'error', 'type': 'GameNotFoundError', 'message': 'Game not found'}
418
+
419
+ game = games[game_id]
420
+
421
+ if action == 'add_player':
422
+ player_data = params['player']
423
+ player = Player(player_data['name'], **player_data.get('attributes', {}))
424
+ game_password = params.get('game_password')
425
+ game.add_player(player, password=game_password)
426
+ return {'status': 'success'}
427
+
428
+ elif action == 'add_observer':
429
+ observer_data = params['observer']
430
+ observer = Observer(observer_data['name'], **observer_data.get('attributes', {}))
431
+ observer_password = params.get('observer_password')
432
+ game.add_observer(observer, password=observer_password)
433
+ return {'status': 'success'}
434
+
435
+ elif action == 'start':
436
+ game.start()
437
+ return {'status': 'success'}
438
+
439
+ elif action == 'pause':
440
+ game.pause()
441
+ return {'status': 'success'}
442
+
443
+ elif action == 'resume':
444
+ game.resume()
445
+ return {'status': 'success'}
446
+
447
+ elif action == 'stop':
448
+ game.stop()
449
+ return {'status': 'success'}
450
+
451
+ elif action == 'next_turn':
452
+ game.next_turn()
453
+ return {'status': 'success'}
454
+
455
+ elif action == 'get_current_player':
456
+ player = game.current_player
457
+ if player:
458
+ return {'status': 'success', 'data': {'name': player.name, 'attributes': player.attributes}}
459
+ else:
460
+ return {'status': 'success', 'data': None}
461
+
462
+ elif action == 'get_game_state':
463
+ return {'status': 'success', 'data': {'status': game.state, 'custom': game.custom_state}}
464
+
465
+ elif action == 'get_players':
466
+ player_list = [{'id': p.ID, 'name': p.name, 'attributes': p.attributes} for p in game.players]
467
+ return {'status': 'success', 'data': player_list}
468
+
469
+ elif action == 'get_observers':
470
+ observer_list = [{'id': o.ID, 'name': o.name, 'attributes': o.attributes} for o in game.observers]
471
+ return {'status': 'success', 'data': observer_list}
472
+
473
+ elif action == 'set_game_state':
474
+ game.custom_state = params.get('state')
475
+ return {'status': 'success'}
476
+
477
+ elif action == 'kick_player':
478
+ player_id = params.get('player_id')
479
+ group_id = params.get('group_id')
480
+ if group_id:
481
+ group = None
482
+ for g in groups.values():
483
+ if g.ID == group_id:
484
+ group = g
485
+ break
486
+ if not group or game not in group.games:
487
+ return {'status': 'error', 'message': f'Game {game_id} does not belong to group ID {group_id}'}
488
+ game.remove_player(player_id)
489
+ return {'status': 'success'}
490
+
491
+ elif action == 'kick_observer':
492
+ observer_id = params.get('observer_id')
493
+ group_id = params.get('group_id')
494
+ if group_id:
495
+ group = None
496
+ for g in groups.values():
497
+ if g.ID == group_id:
498
+ group = g
499
+ break
500
+ if not group or game not in group.games:
501
+ return {'status': 'error', 'message': f'Game {game_id} does not belong to group ID {group_id}'}
502
+ game.remove_observer(observer_id)
503
+ return {'status': 'success'}
504
+
505
+ else:
506
+ return {'status': 'error', 'type': 'ServerError', 'message': 'Unknown action'}
507
+
508
+ except (GameLogicError, PlayerLimitReachedError, ObserverLimitReachedError, AuthenticationError) as e:
509
+ return {'status': 'error', 'type': type(e).__name__, 'message': str(e)}
510
+ except Exception as e:
511
+ return {'status': 'error', 'type': 'ServerError', 'message': str(e)}
512
+
513
+ class GameServer:
514
+ """
515
+ Manages multiple Game instances and handles network requests from clients.
516
+ """
517
+ def __init__(self, host='0.0.0.0', port=65432, password=None, admin_password=None, use_tls=False, tls_domain="localhost", tls_cert=None, tls_key=None, tls_self_signed=True, logging_host=None, logging_port=None, logger_name="GameServer", name=None):
518
+ self.host = host
519
+ self.port = port
520
+ self.password = password
521
+ self.admin_password = admin_password
522
+ self.use_tls = use_tls
523
+ self.tls_domain = tls_domain
524
+ self.tls_cert = tls_cert
525
+ self.tls_key = tls_key
526
+ self.tls_self_signed = tls_self_signed
527
+ self.logging_host = logging_host
528
+ self.logging_port = logging_port
529
+ self.logger_name = logger_name
530
+ self.name = name
531
+ self._server_process = None
532
+ self._discovery_thread = None
533
+ self._stop_discovery = threading.Event()
534
+ self._temp_certs = False
535
+
536
+ def start(self):
537
+ """Starts the game server and discovery service in separate processes/threads."""
538
+ if self._server_process and self._server_process.is_alive():
539
+ print("Server is already running.")
540
+ return
541
+
542
+ certfile, keyfile = (self.tls_cert, self.tls_key)
543
+ self._temp_certs = False
544
+
545
+ if self.use_tls:
546
+ if self.tls_self_signed:
547
+ print(f"Generating self-signed certificate for {self.tls_domain}...")
548
+ certfile, keyfile = _generate_self_signed_cert(self.tls_domain)
549
+ self._temp_certs = True
550
+ elif not certfile or not keyfile:
551
+ # If one is provided but not the other, and self_signed is False, it's an error
552
+ if certfile or keyfile:
553
+ print("Error: Both tls_cert and tls_key must be provided if tls_self_signed is False.")
554
+ return
555
+ # If neither is provided, fallback to self-signed but warn
556
+ print(f"Warning: No certificate provided and tls_self_signed is False. Generating self-signed certificate anyway for {self.tls_domain}...")
557
+ certfile, keyfile = _generate_self_signed_cert(self.tls_domain)
558
+ self._temp_certs = True
559
+ else:
560
+ if not os.path.exists(certfile) or not os.path.exists(keyfile):
561
+ print(f"Error: Certificate file {certfile} or key file {keyfile} not found.")
562
+ return
563
+
564
+ # Auto-detect chain file
565
+ # If cert is 'cert.pem', looks for 'chain.pem'
566
+ # If cert is 'ECC-cert.pem', looks for 'ECC-chain.pem'
567
+ # If cert is 'RSA-cert.pem', looks for 'RSA-chain.pem'
568
+ cert_dir = os.path.dirname(os.path.abspath(certfile))
569
+ cert_name = os.path.basename(certfile)
570
+ if "-cert.pem" in cert_name:
571
+ chain_name = cert_name.replace("-cert.pem", "-chain.pem")
572
+ elif cert_name == "cert.pem":
573
+ chain_name = "chain.pem"
574
+ else:
575
+ chain_name = None
576
+
577
+ if chain_name:
578
+ chain_path = os.path.join(cert_dir, chain_name)
579
+ if os.path.exists(chain_path):
580
+ print(f"Found matching chain file: {chain_name}. Creating full chain...")
581
+ try:
582
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pem", prefix="multiplayer_fullchain_") as tmp_fullchain:
583
+ with open(certfile, 'rb') as f_cert:
584
+ tmp_fullchain.write(f_cert.read())
585
+ if not tmp_fullchain.tell() == 0: # Ensure newline between certs if needed
586
+ tmp_fullchain.write(b"\n")
587
+ with open(chain_path, 'rb') as f_chain:
588
+ tmp_fullchain.write(f_chain.read())
589
+ certfile = tmp_fullchain.name
590
+ except Exception as e:
591
+ print(f"Warning: Failed to create temporary full chain file: {e}. Using original certificate.")
592
+
593
+ # Use 'spawn' start method to avoid DeprecationWarning and potential deadlocks when forking from a multi-threaded process.
594
+ ctx = get_context('spawn')
595
+ self._server_process = ctx.Process(target=_run_server_process, args=(self.host, self.port, self.password, self.admin_password, self.use_tls, certfile, keyfile, self.logging_host, self.logging_port, self.logger_name, self.name))
596
+ self._server_process.daemon = True
597
+ self._server_process.start()
598
+ self._stop_discovery.clear()
599
+ self._discovery_thread = threading.Thread(target=self._run_discovery_service)
600
+ self._discovery_thread.daemon = True
601
+ self._discovery_thread.start()
602
+ start_msg = f"Server started on {self.host}:{self.port} with PID {self._server_process.pid}"
603
+ if self.name:
604
+ start_msg += f" (Name: {self.name})"
605
+ print(start_msg)
606
+ if self.use_tls:
607
+ print("TLS encryption is enabled.")
608
+ print("Network discovery service started.")
609
+
610
+ def stop(self, timeout=5):
611
+ """Stops the game server and discovery service."""
612
+ if self._server_process and self._server_process.is_alive():
613
+ self._server_process.terminate()
614
+ self._server_process.join(timeout=timeout)
615
+ if self._server_process.is_alive():
616
+ print("Server process did not terminate gracefully, killing it...")
617
+ self._server_process.kill()
618
+ self._server_process.join()
619
+ print("Server stopped.")
620
+ else:
621
+ print("Server is not running.")
622
+ if self._discovery_thread and self._discovery_thread.is_alive():
623
+ self._stop_discovery.set()
624
+ self._discovery_thread.join(timeout=timeout)
625
+ print("Network discovery service stopped.")
626
+
627
+ def _run_discovery_service(self):
628
+ """Listens for multicast discovery messages and responds."""
629
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock:
630
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
631
+ # SO_REUSEPORT is necessary for some OS (like MacOS) when binding to the same port
632
+ if hasattr(socket, 'SO_REUSEPORT'):
633
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
634
+
635
+ try:
636
+ sock.bind(('', DISCOVERY_PORT))
637
+ except OSError as e:
638
+ logging.getLogger(self.logger_name).error(f"Failed to bind discovery service to port {DISCOVERY_PORT}: {e}")
639
+ return
640
+
641
+ mreq = struct.pack("4sl", socket.inet_aton(MULTICAST_GROUP), socket.INADDR_ANY)
642
+ try:
643
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
644
+ except OSError as e:
645
+ logging.getLogger(self.logger_name).error(f"Failed to join multicast group: {e}")
646
+ return
647
+
648
+ sock.settimeout(1.0)
649
+ while not self._stop_discovery.is_set():
650
+ try:
651
+ data, addr = sock.recvfrom(1024)
652
+ if data == DISCOVERY_MESSAGE:
653
+ logger = logging.getLogger(self.logger_name)
654
+ logger.info(f"Discovery request from {addr}, sending response...")
655
+ response_ip = self._get_lan_ip()
656
+ response_port = self.port
657
+ message = struct.pack(RESPONSE_MESSAGE_FORMAT, response_ip.encode('utf-8'), response_port)
658
+ sock.sendto(message, addr)
659
+ except socket.timeout:
660
+ continue
661
+ except Exception as e:
662
+ logging.getLogger(self.logger_name).error(f"Error in discovery service: {e}")
663
+
664
+ def _get_lan_ip(self):
665
+ """Helper to get the local LAN IP address."""
666
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
667
+ try:
668
+ # doesn't even have to be reachable
669
+ s.connect(('10.255.255.255', 1))
670
+ ip = s.getsockname()[0]
671
+ except Exception:
672
+ ip = '127.0.0.1'
673
+ finally:
674
+ s.close()
675
+ return ip
676
+