somethinglikegames.de

Mein Blog über Spieleentwicklung und das Drumherum


Kategorien


Tags

Der Quellcode zu den Artikeln ist in diesem GitHub Repository zu finden. Die folgende Liste zeigt alle bislang erschienenen Artikel dieser Serie:

  1. Netzwerktutorial 1: Allgemeines und Übersicht
  2. Netzwerktutorial 2: Das "gehende Skelett"
  3. Netzwerktutorial 3: Login 1 - Der Game-Client
  4. Netzwerktutorial 4: Login 2 - Gateway- und Authentication-Server
  5. Netzwerktutorial 5: Login 3 - World-Server
  6. Netzwerktutorial 6: Verschlüsselte Verbindungen mit Hilfe von DTLS

Im letzten Artikel haben wir den Gateway- und den Authentication-Server fertig implementiert, sodass der Game-Client für gültige Zugangsdaten ein Token ausgestellt bekommt. Dieses Token soll nun dazu dienen, sich am World-Server anzumelden. Daher werden wir in diesem Artikel den Game-Client so erweitern, dass er den Token zum World-Server weiterleitet, um anschließend den World-Server in die Lage zu versetzen, den Token zu verifizieren.

Erweiterung des Game-Clients

Bislang endet der Loginvorgang im Game-Client in folgender Funktion von main.gd:

33func _login_callback(return_code: int, token: String) -> void:
34	gateway_server.queue_free()
35	if return_code == OK:
36		print("Login was successful")
37		print("Current Token is: %s" % token)
38		# Connect to World Server
39	else :
40		print("Something went wrong…")
41		get_node("LoginScreen").reset()

Wir haben bereits eine minimale Fehlerbehandlung, sodass die Oberfläche wieder zurückgesetzt wird, wenn etwas beim Login fehlschlägt (Zeile 41). Aber im Erfolgsfall enden wir aktuell beim einst gut gemeinten Kommentar in Zeile 38. Daher werden wir nun alle Vorbereitungen treffen, um den Kommentar durch wirkliche Funktionalität zu ersetzen. Zuerst legen wir ein neues Skript namens world_server.gd unterhalb von scenes/network an.

World-Server Netzwerkclient (./scenes/network/world_server.gd)

Das nun nachfolgende Grundgerüst sollte überwiegend aus dem Client für den Gateway-Server bekannt sein. Wahrscheinlich sollte man die Gemeinsamkeiten mal in eine Oberklasse extrahieren, aber das würde es an dieser Stelle unübersichtlicher machen. Die Funktion connect_to_server() (Zeile 9-25) versucht, eine Verbindung zum World-Server aufzubauen. Server-Adresse und -Port werden wiederum aus einem NetworkConnectionData-Objekt genommen, welches wir vorher setzen müssen. Die berücksichtigten Fehlerfälle (Zeilen 18 und 20) werden wie gewohnt mit Hilfe von _on_connection_failed() abgehandelt. Um im Erfolgsfall in unserem Anmeldeprozess weiterzumachen, verbinden wir das Signal connected_to_server von unserem MultiplayerAPI-Objekt mit der Funktion _on_connection_succeeded() (Zeile 17).

 1extends Node
 2
 3var network := ENetMultiplayerPeer.new()
 4var world_server : NetworkConnectionData
 5var token : String
 6var callback : Callable
 7
 8
 9func connect_to_server(_token: String, _callback: Callable) -> void:
10	token = _token
11	callback = _callback
12
13	print("Connecting to %s" % world_server.to_string())
14	var ret = network.create_client(world_server.address, world_server.port)
15	if ret == OK:
16		get_multiplayer().multiplayer_peer = network
17		get_multiplayer().connected_to_server.connect(_on_connection_succeeded)
18		get_multiplayer().connection_failed.connect(_on_connection_failed.bind(FAILED))
19	else:
20		_on_connection_failed(ret)
21
22
23func _on_connection_failed(ret: int) -> void:
24	print("Failed to connect to world server on %s, errorcode was %d"
25			% [world_server.to_string(), ret])
26	callback.call(ret, "")

Sobald die Verbindung zum Server besteht, wird die Funktion _on_connection_succeeded() aufgerufen. Dort übermitteln wir dann den Token an den World-Server (Zeile 32). Das Resultat der tokenbasierten Anmeldung bekommen wir als Übergabeparameter der Funktion c_login_response(). Dort heben wir die Verknüpfung zum Signal connected_to_server auf, da wir ja nun nicht mehr im Besitz eines Tokens sind. Anschließend wird der übergebene Callback aufgerufen, sodass der eigentliche Programmfluss im Game-Client weiterlaufen kann.

29func _on_connection_succeeded() -> void:
30	print("Succesfully connected to world server %s" % [world_server.to_string()])
31	print("send login request to world server")
32	s_login_request.rpc_id(MultiplayerPeer.TARGET_PEER_SERVER, token)
33	token = ""
34
35
36@rpc("call_remote", "any_peer", "reliable")
37func s_login_request(_token: String) -> void:
38	pass # on game server
39
40
41@rpc("call_remote", "authority", "reliable")
42func c_login_response(result: bool) -> void:
43	print("login request result is %s" % str(result))
44	get_multiplayer().connected_to_server.disconnect(_on_connection_succeeded)
45	callback.call(OK if result else ERR_INVALID_DATA)

Main-Szene (./scenes/main.gd)

Nun haben wir unseren Netzwerkclient für den World-Server soweit vorbereitet, dass wir ihn nun auch in den eigentlichen Programmfluss integrieren können. Daher wird die bisherige Funktion _login_callback() so erweitert, dass sie nun den eben erstellten Netzwerkclient nutzt, um den Token an den World-Server zu übermitteln. Damit das nachfolgend gezeigte Code-Fragment auch funktioniert, habe ich noch eine neue Skriptvariable namens world_server vom Typ Node eingefügt, ähnlich wie die bereits vorher vorhandene Variable gateway_server.

34func _login_callback(return_code: int, token: String) -> void:
35	gateway_server.queue_free()
36	gateway_server = null
37	if return_code == OK:
38		print("Current Token is: %s" % token)
39		world_server = Node.new()
40		world_server.name = "WorldServer"
41		add_child(world_server)
42		world_server.set_script(load("res://scenes/network/world_server.gd"))
43		get_tree().set_multiplayer(SceneMultiplayer.new(), ^"/root/Main/WorldServer")
44		world_server.world_server = world_server_data
45		world_server.connect_to_server(token, _world_server_callback)
46	else :
47		print("Something went wrong…")
48		get_node("LoginScreen").reset()

Der neu hinzugefügte Quellcode (Zeilen 39-45) bindet den Netzwerkclient des World-Servers in den SceneTree ein, um anschließend dort die Funktion login_to_server() mit dem Token aufzurufen. Dafür habe ich den bereits aus Tutorial 3 vorhandenen Code für den Gateway-Server-Client kopiert und an den World-Server angepasst. Als Callback wurde eine neue Funktion _world_server_callback übergeben, die vorerst folgendermaßen aussieht:

52func _world_server_callback(return_code: int) -> void:
53	if return_code == OK:
54		print("Login to world server was successful")
55		# Add further game logic
56	else:
57		gateway_server.queue_free()
58		gateway_server = null
59		print("Login to world server failed")
60		get_node("LoginScreen").reset()

Der Fehlerfall führt, wie zuvor bereits, zum Reset der Oberfläche, sodass ein erneuter Loginversuch unternommen werden kann, und im Erfolgsfall enden wir nun vorerst bei einem neuen Kommentar 😉 in Zeile 55.

Erweiterung des World-Servers

Auch wenn der Game-Client nun vorbereitet ist, um sich beim World-Server anzumelden, ist es der World-Server noch lange nicht. Dieser besteht bislang nur aus dem Servergrundgerüst und weiß noch gar nichts von einer Anmeldungsmöglichkeit, geschweige denn von Tokenvalidierung. Daher ist der erste Schritt die Installation des AddOns GDScript JWT, ähnlich wie im letzten Tutorial beim Authentication-Server. Dafür laden wir zunächst das aktuellste Release für Godot 4 herunter und entpacken den Ordner addons in unser Godot-Projekt des World-Servers. Anschließend muss das AddOn evtl. noch in den Projekteinstellungen -> Plugins aktiviert werden. Danach kopieren wir den öffentlichen Schlüssel vom Authentication-Server aus dem letzten Tutorial in einen neuen Ordner crypto unterhalb des World-Server-Projektverzeichnisses.

World-Server (./scenes/network/world_server.gd)

Die Vorbereitungen für die Validierung der JWT-Tokens sind ähnlich wie für die Erzeugung solcher Tokens. Zuerst legen wir uns eine Skriptvariable vom Typ JWTAlgorithm an, um sie dann innerhalb von _ready() zu initialisieren. Ein essentieller Unterschied ist, dass wir für die Validierung ausschließlich den öffentlichen Schlüssel benötigen und dies daher beim Laden des Schlüssels (Zeile 10) als zweiten Parameter angeben müssen. Diesen Schlüssel hinterlegen wir in unserer JWTAlgorithm-Variable als _public_crypto und setzen natürlich identisch zum Authentication-Server JWTAlgorithm.Type.RSA256 als Algorithmus.

 6var jwt_algorithm: JWTAlgorithm
 7
 8func _ready() -> void:
 9	var public_key := CryptoKey.new()
10	var load_ret := public_key.load("res://crypto/jwt_rsa.pem", true)
11	if load_ret == OK:
12		jwt_algorithm = JWTAlgorithm.new()
13		jwt_algorithm._public_crypto = public_key
14		jwt_algorithm._alg = JWTAlgorithm.Type.RSA256
15	else:
16		print("Error while reading RSA public key: %d" % load_ret)
17		get_tree().quit(load_ret)

Die Überprüfung der Tokens findet innerhalb von s_login_request statt. Dafür erzeugen wir mit Hilfe des Tokens einen JWTDecoder (Zeile 45) und verifizieren anschließend mit Hilfe des in _ready() initialisierten JWTAlgorithm (Zeile 46) die Signatur. Danach stellen wir sicher, dass der Token auch noch gültig ist (Zeile 47). Sollte der Token sowohl über eine gültige Signatur verfügen als auch noch gültig sein, senden wir ein positives Ergebnis an den Client zurück. Falls einer der Tests negativ ausgefallen ist, senden wir zuerst eine negative Antwort an den Client und trennen anschließend die Verbindung.

41@rpc("call_remote", "any_peer", "reliable")
42func s_login_request(token: String) -> void:
43	var now := int(Time.get_unix_time_from_system())
44	var user_id := get_multiplayer().get_remote_sender_id()
45	var jwt := JWTDecoder.new(token)
46	var is_signature_valid := jwt_algorithm.verify(jwt)
47	var is_unexpired = now <= jwt.get_expires_at()
48	if is_signature_valid and is_unexpired:
49		c_login_response.rpc_id(user_id, true)
50	else:
51		c_login_response.rpc_id(user_id, false)
52		disconnect_player(user_id)
53
54
55@rpc("call_remote", "authority", "reliable")
56func c_login_response(_result: bool) -> void:
57	pass # on game client
58
59
60func disconnect_player(user_id: int) -> void:
61	get_multiplayer().disconnect_peer(user_id)

Damit ist nun (endlich) unser Loginvorgang vollständig und unser Game-Client hat eine authentifizierte Verbindung zum World-Server aufgebaut, ohne dass der World-Server über Passwörter verfügen muss.

Erlangen von Verbindungsinformationen

Zum Ausklang dieses Tutorials möchte ich noch über eine weitere Neuerung von Godot 4 sprechen: “Lower-level ENet”. ENet hat auch neben der reinen Netzwerkkommunikation noch einige interessante Features, die sehr nützlich sein können. Beispielsweise hat man bei ENet eigentlich eine direkte Möglichkeit, die “Round-Trip-Time” (RTT) einer Verbindung abzufragen. Nur bis Godot 4 war ENet so gekapselt, dass man innerhalb von GDScript keinen Zugriff auf diese Informationen hatte. Daher freut es mich, dass neben den unglaublich vielen anderen Änderungen in Godot 4 auch dies geändert wurde. Somit existieren nun mit ENetConnection und ENetPacketPeer Wrapper, die Zugriff auf die eigentliche ENet-Funktionalität bieten. Fabio Alessandrelli (Faless) hat dazu auch einen eigenen lesenswerten Blogpost als Progress Report auf der Godot-Seite verfasst.

Um diese neuen Möglichkeiten auszutesten, wäre es vielleicht eine gute Idee, den Game-Client so zu erweitern, dass er regelmäßig die RTT in Richtung World-Server ausgibt. Dafür erweitern wir zuerst ./scenes/network/world_server.gd um eine neue Skriptvariable, in der wir uns unser ENetPacketPeer-Objekt speichern:

4var peer : ENetPacketPeer = null

Da der Game-Client als Netzwerkclient nur mit dem World-Server in Verbindung steht, kennt er nur einen Peer. Daher ist es meiner bescheidenen Meinung nach am einfachsten, an das passende Objekt zu kommen, indem wir uns über unser ENetMultiplayerPeer-Objekt Zugriff auf die zugehörige ENetConnection verschaffen, bei der wir uns dann den ersten (und einzigen) ENetPacketPeer abgreifen (Zeile 46).

41@rpc("call_remote", "authority", "reliable")
42func c_login_response(result: bool) -> void:
43	print("login request result is %s" % str(result))
44	get_multiplayer().connected_to_server.disconnect(_on_connection_succeeded)
45	if result:
46		peer = network.get_host().get_peers()[0]
47	callback.call(OK if result else ERR_INVALID_DATA)

So können wir anschließend jederzeit get_statistic() auf unserer Netzwerkverbindung aufrufen und beispielsweise eine Funktion zur Abfrage der durchschnittlichen RTT zur Verfügung stellen:

50func get_rtt() -> float:
51	if peer:
52		return peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME)
53	else:
54		return -1.0

Jetzt müssen wir nur noch unser Skript zur Main-Szene ./scenes/main.gd etwas erweitern. Zuerst fügen wir eine Variable für einen Timer hinzu:

8var rtt_timer : Timer

Und dann erweitern wir noch _world_server_callback(). Sollten wir einen erfolgreichen Verbindungsaufbau zum World-Server geschafft haben, erzeugen wir einen neuen Timer, fügen ihn dem SceneTree hinzu, verknüpfen das timeout-Signal des Timers und starten diesen mit einem Intervall von 5 Sekunden. In der Funktion, die nun durch das timeout-Signal des Timers aufgerufen wird, fragen wir unsere RTT ab.

54func _world_server_callback(return_code: int) -> void:
55	if return_code == OK:
56		print("Login to world server was successful")
57		rtt_timer = Timer.new()
58		add_child(rtt_timer)
59		rtt_timer.timeout.connect(_on_rtt_timer_timeout)
60		rtt_timer.start(5)
61		# Add further game logic
62	else:
63		gateway_server.queue_free()
64		gateway_server = null
65		print("Login to world server failed")
66		get_node("LoginScreen").reset()
67
68
69func _on_rtt_timer_timeout() -> void:
70	if world_server:
71		print("RTT: %f" % world_server.get_rtt())
Man darf sich über die relativ hohen Werte ~15-20ms für die RTT bei lokalen Tests nicht wundern. Das klingt zwar im ersten Moment hoch, da die Verbindung ja lokal ist, aber man muss bedenken, dass die RTT nicht nur Hin- und Rückweg beinhaltet, was ja lokal entfällt, sondern auch die Verarbeitungszeit auf dem Server beinhaltet.

Der nächste Schritt

Bevor wir anfangen werden, Spielercharaktere zu spawnen oder aktiv Nachrichten zwischen Game-Client und World-Server auszutauschen, was alles vorgemerkte Themen für diese Tutorialserie sind, werden wir uns im nächsten Tutorial mit DTLS beschäftigen. Wir kümmern uns also darum, die Netzwerkverbindungen zwischen allen Kommunikationspartnern zu verschlüsseln, um einerseits vom Game-Client verschickte Passwörter zu schützen und andererseits das “Einhacken” von bspw. Cheat-Software zu erschweren.