Skip to content

Commit 5620afd

Browse files
committed
Adding TLS
1 parent caad02f commit 5620afd

File tree

5 files changed

+225
-85
lines changed

5 files changed

+225
-85
lines changed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ A lightweight server and framework for turn-based multiplayer games.
1010

1111
![](.github/game_server.svg)
1212

13+
Basic Python skills are sufficient to implement clients or add new games to the server.
14+
1315
## Overview
1416

1517
### Features
@@ -32,11 +34,11 @@ To try this project on your machine, start the server (`server/game_server.py`),
3234

3335
### About this project
3436

35-
This server was developed for use in a university programming course, where students learn Python as their first programming language and have to work on projects in small groups. Both the framework and the API are designed so that the programming skills acquired during the first term are sufficient to implement new games and clients. However, the use of the server is not limited to educational scenarios.
37+
This server was developed for use in a university programming course, where students learn Python as their first programming language and work on projects in small groups. Both the framework and the API are designed so that the programming skills acquired during the first term are sufficient to implement new games and clients. However, the use of the server is not limited to educational scenarios.
3638

3739
## Operating the server
3840

39-
To run the server in a network, edit IP and port in the configuration file (`server/config.py`). If you intend to run the server as a systemd service, you can use the provided unit file as a starting point. Server and API are implemented in plain Python. Only modules from the standard library are used. This makes the server easy to handle.
41+
To run the server in a network, edit IP and port in the configuration file (`server/config.py`). TLS can also be enabled there. If you intend to run the server as a systemd service, you can use the provided unit file. Server and API are implemented in plain Python. Only modules from the standard library are used. This makes the server easy to handle.
4042

4143
## Implementing clients
4244

@@ -46,7 +48,8 @@ Module `game_server_api` provides an API for communicating with the server. It a
4648
- submit moves
4749
- retrieve the game state
4850
- passively observe another player
49-
- start a new game within the current session
51+
- restart a game within the current session
52+
- enable TLS
5053

5154
Here is a short demo of the API usage:
5255

client/game_server_api.py

Lines changed: 124 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,18 @@
2626
- submit moves to the server
2727
- retrieve the game state
2828
- passively observe a specific player
29-
- restart a game without starting a new session
29+
- restart a game within the current session
30+
- enable TLS
3031
"""
3132

3233
import json
34+
import os
3335
import socket
36+
import ssl
3437
import traceback
3538

36-
class GameServerError(Exception):
37-
pass
38-
39-
class IllegalMove(Exception):
40-
pass
39+
class GameServerError(Exception): pass
40+
class IllegalMove(Exception): pass
4141

4242
class GameServerAPI:
4343
"""
@@ -101,8 +101,9 @@ def __init__(self, server, port, game, session='auto', players=None, name=''):
101101
self._observer = False
102102

103103
# tcp connections:
104-
self._buffer_size = 4096 # bytes, corresponds to server-side buffer size value
104+
self._buffer_size = 4096 # bytes, corresponds to server-side buffer size
105105
self._request_size_max = int(1e6) # bytes, updated after joining a game
106+
self._tls_context = None
106107

107108
def join(self):
108109
"""
@@ -300,6 +301,34 @@ def restart(self):
300301

301302
if err: raise GameServerError(err)
302303

304+
def enable_tls(self, cert=''):
305+
"""
306+
Calling this function enables TLS encryption. By providing a
307+
certificate, identity verification of the server is performed in
308+
addition to encryption.
309+
310+
The server must have TLS enabled.
311+
312+
Parameters:
313+
cert (str): certificate file (optional)
314+
315+
Raises:
316+
GameServerError: if loading the certificate failed
317+
"""
318+
try:
319+
self._tls_context = ssl.create_default_context()
320+
321+
if cert:
322+
cert = self._abs_path(cert)
323+
self._tls_context.load_verify_locations(cert)
324+
else:
325+
self._tls_context.check_hostname = False
326+
self._tls_context.verify_mode = ssl.CERT_NONE
327+
except (FileNotFoundError, IsADirectoryError, TypeError):
328+
raise GameServerError('the specified certificate file could not be found')
329+
except ssl.SSLError as e:
330+
raise GameServerError(f'TLS error while loading certificate: {e}')
331+
303332
def _send(self, data):
304333
"""
305334
Send data to the server and receive its response.
@@ -315,7 +344,7 @@ def _send(self, data):
315344
tuple(dict, str, str):
316345
dict: data returned by server, None in case of an error
317346
str: error message if a problem occurred, None otherwise
318-
str: status (okay or error type)
347+
str: error type in case of an error, okay otherwise
319348
"""
320349
# prepare data:
321350
try:
@@ -328,47 +357,92 @@ def _send(self, data):
328357

329358
# create a socket:
330359
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sd:
331-
try:
332-
# connect to server:
333-
sd.settimeout(5)
334-
sd.connect((self._server, self._port))
335-
sd.settimeout(None) # let server handle timeouts
336-
except:
337-
return self._api_error(f'unable to connect to {self._server}:{self._port}')
338-
339-
try:
340-
# send data to server:
341-
sd.sendall(request)
342-
343-
# receive data from server:
344-
response = bytearray()
345-
346-
while True:
347-
data = sd.recv(self._buffer_size)
348-
if not data: break
349-
response += data
350-
351-
if not response: raise self._NoResponse
352-
response = json.loads(response.decode())
353-
354-
# return data:
355-
if response['status'] != 'ok': # server responded with an error
356-
return None, response['message'], response['status']
357-
358-
return response['data'], None, None
359-
360-
except socket.timeout:
361-
return self._api_error('connection timed out')
362-
except self._NoResponse:
363-
return self._api_error('empty or no response received from server')
364-
except (ConnectionResetError, BrokenPipeError):
365-
return self._api_error('connection closed by server')
366-
except UnicodeDecodeError:
367-
return self._api_error('could not decode binary data received from server')
368-
except json.decoder.JSONDecodeError:
369-
return self._api_error('corrupt json received from server')
370-
except:
371-
return self._api_error('unexpected exception:\n' + traceback.format_exc())
360+
with self._secure_socket(sd) as sd:
361+
try:
362+
# connect to server:
363+
sd.settimeout(5)
364+
sd.connect((self._server, self._port))
365+
sd.settimeout(None) # let server handle timeouts
366+
except ssl.SSLError as e:
367+
return self._api_error(f'TLS error: {e}')
368+
except:
369+
return self._api_error(f'unable to connect to {self._server}:{self._port}')
370+
371+
try:
372+
# send data to server:
373+
sd.sendall(request)
374+
375+
# receive server response:
376+
response = bytearray()
377+
378+
while True:
379+
data = sd.recv(self._buffer_size)
380+
if not data: break
381+
response += data
382+
383+
if not response: raise self._NoResponse
384+
response = json.loads(response.decode())
385+
386+
# return data:
387+
if response['status'] != 'ok': # server responded with an error
388+
return None, response['message'], response['status']
389+
390+
return response['data'], None, None
391+
392+
except socket.timeout:
393+
return self._api_error('connection timed out')
394+
except self._NoResponse:
395+
return self._api_error('empty or no response received from server')
396+
except (ConnectionResetError, BrokenPipeError):
397+
return self._api_error('connection closed by server')
398+
except UnicodeDecodeError:
399+
return self._api_error('could not decode binary data received from server')
400+
except json.decoder.JSONDecodeError:
401+
return self._api_error('corrupt json received from server')
402+
except:
403+
return self._api_error('unexpected exception:\n' + traceback.format_exc())
404+
405+
def _secure_socket(self, socket):
406+
"""
407+
This function wraps the socket and returns a TLS socket. TLS must be
408+
enabled by calling API function enable_tls. Otherwise, the passed socket
409+
is returned unmodified. If a certificate was passed to function
410+
enable_tls, identity verification of the server is enabled. Without a
411+
certificate, TLS is used for encryption only.
412+
413+
Parameters:
414+
socket (socket): a regular socket
415+
416+
Returns:
417+
socket or SSLSocket: a TLS socket, if TLS is enabled, the unmodified socket otherwise
418+
419+
Raises:
420+
ssl.SSLError: if the creation of the TLS socket failed (lazy, raised after connect())
421+
"""
422+
if self._tls_context:
423+
return self._tls_context.wrap_socket(socket, server_hostname=self._server)
424+
425+
return socket
426+
427+
def _abs_path(self, file_name):
428+
"""
429+
Always returns the file name with its absolute path, regardless of where
430+
the file is located or from where the program was called.
431+
432+
Parameters:
433+
file_name (str): file name with relative or absolute path
434+
435+
Returns:
436+
str: file name with absolute path
437+
438+
Raises:
439+
TypeError: if argument is not of type str
440+
IsADirectoryError: if argument is a directory
441+
"""
442+
if not file_name or os.path.isabs(file_name):
443+
return file_name
444+
445+
return os.path.join(os.path.abspath(os.path.dirname(__file__)), file_name)
372446

373447
@staticmethod
374448
def _api_error(message):
@@ -381,5 +455,4 @@ def _api_error(message):
381455
def _error(message):
382456
return 'Invalid argument: ' + message
383457

384-
class _NoResponse(Exception):
385-
pass
458+
class _NoResponse(Exception): pass

server/config.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,28 @@
2121
"""
2222

2323
# SERVER:
24-
ip = '127.0.0.1'
24+
ip = '127.0.0.1' # IP or hostname
2525
port = 4711
2626

2727
# FRAMEWORK:
2828
game_timeout = 1000 # seconds, timeout for inactive games and for joining a game
2929

3030
# LOGGING:
31-
log_server_info = False # useful for debugging tcp connections (verbose)
3231
log_server_errors = True # errors during tcp connections
32+
log_server_info = False # useful for debugging tcp connections (verbose)
3333
log_framework_request = True # client requests
3434
log_framework_response = True # server responses
3535
log_framework_actions = True # actions performed by the framework, such as terminating games
3636

3737
# TCP CONNECTIONS:
38-
# pick a higher value for request_size_max if required by a new game;
39-
# it should not be necessary to change buffer_size or connection_timeout
38+
# pick a higher value for request_size_max if required by a new game
4039
request_size_max = int(1e6) # bytes, prevents clients from sending too much data
40+
41+
# it should not be necessary to change buffer_size or connection_timeout
4142
buffer_size = 4096 # bytes, corresponds to client-side buffer size value
4243
connection_timeout = 60 # seconds, timeout for tcp transactions
44+
45+
# TLS:
46+
# to enable TLS, specify certificate and key (clients must enable TLS as well)
47+
tls_cert = ''
48+
tls_key = ''

server/game_server.py

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
import json
3030
import socket
31+
import ssl
3132
import threading
3233
import traceback
3334

@@ -40,7 +41,7 @@
4041
class ClientDisconnect(Exception): pass
4142
class RequestSizeExceeded(Exception): pass
4243

43-
def handle_connection(conn, ip, port):
44+
def handle_connection(conn, client):
4445
"""
4546
Handling a connection.
4647
@@ -57,10 +58,10 @@ def handle_connection(conn, ip, port):
5758
possible, error messages are sent back to the client.
5859
5960
Parameters:
60-
conn (socket): connection socket
61-
ip (str): client IP
62-
port (int): client port
61+
conn (socket or SSLSocket): connection socket
62+
client (tuple(str, int)): client IP and port
6363
"""
64+
ip, port = client
6465
log = utility.ServerLogger(ip, port)
6566
log.info('connection accepted')
6667

@@ -133,25 +134,63 @@ def handle_connection(conn, ip, port):
133134
conn.close()
134135
log.info('connection closed by server')
135136

137+
def secure_socket(socket):
138+
"""
139+
This function wraps the socket and returns a TLS socket. TLS must be enabled
140+
in the config module. Otherwise, the passed socket is returned unmodified.
141+
This function terminates the server program if an error occurs.
142+
143+
Parameters:
144+
socket (socket): a regular listening socket
145+
146+
Returns:
147+
socket or SSLSocket: a TLS socket, if TLS is enabled, the unmodified socket otherwise
148+
"""
149+
if config.tls_cert and config.tls_key:
150+
try:
151+
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
152+
context.minimum_version = ssl.TLSVersion.TLSv1_2
153+
context.load_cert_chain(
154+
certfile=utility.abs_path(config.tls_cert),
155+
keyfile=utility.abs_path(config.tls_key))
156+
print('TLS enabled')
157+
158+
return context.wrap_socket(socket, server_side=True)
159+
160+
except (FileNotFoundError, IsADirectoryError, TypeError):
161+
exit('Error: the specified key or certificate file could not be found')
162+
except ssl.SSLError as e:
163+
exit(f'TLS error while loading certificate and key: {e}')
164+
165+
return socket
166+
136167
print("""This is free software with ABSOLUTELY NO WARRANTY.
137-
Licensed under the GPL version 3 (see LICENSE).""")
168+
Licensed under the GPL version 3 (see LICENSE).
169+
""")
138170

139-
try:
140-
# create listening socket:
141-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sd:
142-
sd.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
143-
sd.bind((config.ip, config.port))
144-
sd.listen()
171+
print('Server starting')
145172

146-
print(f'Listening on {config.ip}:{config.port}')
173+
# create listening socket:
174+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sd:
175+
sd.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
176+
sd.bind((config.ip, config.port))
177+
sd.listen()
147178

179+
with secure_socket(sd) as sd:
180+
print(f'Listening on {config.ip if config.ip else 'any'}:{config.port}')
181+
log = utility.ServerLogger()
182+
183+
# accept connections and handle them in separate threads:
148184
while True:
149-
# accept a connection:
150-
conn, client = sd.accept()
151-
ip, port = client
152-
153-
# handle connection in separate thread:
154-
t = threading.Thread(target=handle_connection, args=(conn, ip, port), daemon=True)
155-
t.start()
156-
except KeyboardInterrupt:
157-
print('')
185+
try:
186+
conn, client = sd.accept()
187+
188+
threading.Thread(target=handle_connection, args=(conn, client),
189+
daemon=True).start()
190+
except KeyboardInterrupt:
191+
print('\nServer shutting down')
192+
exit()
193+
except ssl.SSLError as e:
194+
log.error(f'TLS error: {e}')
195+
except:
196+
log.error('unexpected exception on the server:\n' + traceback.format_exc())

0 commit comments

Comments
 (0)