This tutorial will cover using Godot’s StreamPeerSSL class to create a TLS/SSL connection to a server. It builds off of my TCP client tutorial where we created a TCP (non-SSL) connection. You will need the completed client.gd file found at the end of the Client Class section (in the aforementioned TCP client tutorial) to start from for the remainder of this tutorial.

Disclaimer

I am not a security expert and claim no guarantees on the safety of using this technique. If the information and/or servers you need to protect need strong security then you should seek the advice of a professional security engineer. This tutorial is provided for informational purposes only and you are responsible for your own security.

SSL Client

We will take our previously created TCP client and make some modifications to upgrade the connection to a SSL connection.

Step 1: Change the _stream variable’s type from StreamPeerTCP to StreamPeerSSL. The StreamPeerSSL class provides the underlying mechanisms to create and manage the SSL connection.

- var _stream: StreamPeerTCP = StreamPeerTCP.new()
+ var _stream: StreamPeerSSL = StreamPeerSSL.new()

Step 2: The connect_to_host function needs to be updated to make use of the SSL stream. The StreamPeerSSL class expects to use an existing TCP connection, which it then upgrades to a SSL connection.

Create a new TCP stream peer and attempt a connection to the server using the connect_to_host function. We will check the return value for an error and emit the error signal if the connection failed.

Next, upgrade TCP connection to a SSL connection by passing it to the SSL stream using the connect_to_stream function. If this function fails, it will return an error which is printed to the console. We do not emit the error signal here because the error will be caught and emitted during the _process step and we do not want to emit the error twice.

The StreamPeerSSL class also uses a different set of statuses than the StreamPeerTCP class. Swap out the STATUS_NONE for STATUS_DISCONNECTED.

func connect_to_host(host: String, port: int) -> void:
	print("Connecting to %s:%d" % [host, port])
	# Reset status so we can tell if it changes to error again.
-	_status = _stream.STATUS_NONE
-	if _stream.connect_to_host(host, port) != OK:
-		print("Error connecting to host.")
-		emit_signal("error")
+	_status = _stream.STATUS_DISCONNECTED
+	var tcp: StreamPeerTCP = StreamPeerTCP.new()
+	var error: int = tcp.connect_to_host(host, port)
+	if error != OK:
+		print("Error connecting to host: ", error)
+		emit_signal("error")
+		return
+	error = _stream.connect_to_stream(tcp)
+	if error != OK:
+		print("Error upgrading connection to SSL: ", error)

Step 2 (optional): If you need to validate the server certificate, then you will want to use the optional parameters to the connect_to_stream function. You can pass a boolean on whether to validate the server certificate, the valid server host name, and a valid server certificate to accept. The certificate will need to be a X509Certificate resource.

+ error = _stream.connect_to_stream(tcp, true, "server.hostname.com", server_certificate)

Step 3: Inside of the _process function change the STATUS_NONE case in the match statement to STATUS_DISCONNECTED. The disconnected status will be treated the same as the previous none status.

Remove the STATUS_CONNECTING case and add the HANDSHAKING and ERROR_HOSTNAME_MISMATCH cases as seen below. The handshaking status will print a message indicating that the handshake is currently taking place. The host name mismatch error status will print a message indicating that there is an error and emit the error signal to inform the user.

func _process(delta: float) -> void:
	var new_status: int = _stream.get_status()
	if new_status != _status:
		_status = new_status
		match _status:
-			_stream.STATUS_NONE:
+			_stream.STATUS_DISCONNECTED:
				print("Disconnected from host.")
				emit_signal("disconnected")
-			_stream.STATUS_CONNECTING:
-				print("Connecting to host.")
			_stream.STATUS_CONNECTED:
				print("Connected to host.")
				emit_signal("connected")
			_stream.STATUS_ERROR:
				print("Error with socket stream.")
				emit_signal("error")
+			_stream.STATUS_HANDSHAKING:
+				print("Performing SSL handshake with host.")
+			_stream.STATUS_ERROR_HOSTNAME_MISMATCH:
+				print("Error with socket stream: Hostname mismatch.")
+				emit_signal("error")

Step 4: Poll the _stream object inside of the _process method. This ensures that the next call to get_available_bytes will be accurate and that the connection will break if the server closes the connection.

	if _status == _stream.STATUS_CONNECTED:
+		# Poll the stream to ensure connection is valid and check for availalbe bytes.
+		_stream.poll()
		var available_bytes: int = _stream.get_available_bytes()
		if available_bytes > 0:

That’s it! The final SSL client looks like the following:

extends Node

signal connected
signal data
signal disconnected
signal error

var _status: int = 0
var _stream: StreamPeerSSL = StreamPeerSSL.new()

func _ready() -> void:
	_status = _stream.get_status()

func _process(delta: float) -> void:
	var new_status: int = _stream.get_status()
	if new_status != _status:
		_status = new_status
		match _status:
			_stream.STATUS_DISCONNECTED:
				print("Disconnected from host.")
				emit_signal("disconnected")
			_stream.STATUS_HANDSHAKING:
				print("Performing SSL handshake with host.")
			_stream.STATUS_CONNECTED:
				print("Connected to host.")
				emit_signal("connected")
			_stream.STATUS_ERROR:
				print("Error with socket stream.")
				emit_signal("error")
			_stream.STATUS_ERROR_HOSTNAME_MISMATCH:
				print("Error with socket stream: Hostname mismatch.")
				emit_signal("error")

	if _status == _stream.STATUS_CONNECTED:
		# Poll the stream to ensure connection is valid and check for availalbe bytes.
		_stream.poll()
		var available_bytes: int = _stream.get_available_bytes()
		if available_bytes > 0:
			print("available bytes: ", available_bytes)
			var data: Array = _stream.get_partial_data(available_bytes)
			# Check for read error.
			if data[0] != OK:
				print("Error getting data from stream: ", data[0])
				emit_signal("error")
			else:
				emit_signal("data", data[1])

func connect_to_host(host: String, port: int) -> void:
	print("Connecting to %s:%d" % [host, port])
	# Reset status so we can tell if it changes to error again.
	_status = _stream.STATUS_DISCONNECTED
	var tcp: StreamPeerTCP = StreamPeerTCP.new()
	var error: int = tcp.connect_to_host(host, port)
	if error != OK:
		print("Error connecting to host: ", error)
		emit_signal("error")
		return
	error = _stream.connect_to_stream(tcp)
	if error != OK:
		print("Error upgrading connection to SSL: ", error)

func send(data: PoolByteArray) -> bool:
	if _status != _stream.STATUS_CONNECTED:
		print("Error: Stream is not currently connected.")
		return false
	var error: int = _stream.put_data(data)
	if error != OK:
		print("Error writing to stream: ", error)
		return false
	return true

Testing

We can use the same main.gd as from the TCP client tutorial. It is repeated below for convenience.

extends Node

const HOST: String = "127.0.0.1"
const PORT: int = 5000
const RECONNECT_TIMEOUT: float = 3.0

const Client = preload("res://client.gd")
var _client: Client = Client.new()

func _ready() -> void:
	_client.connect("connected", self, "_handle_client_connected")
	_client.connect("disconnected", self, "_handle_client_disconnected")
	_client.connect("error", self, "_handle_client_error")
	_client.connect("data", self, "_handle_client_data")
	add_child(_client)
	_client.connect_to_host(HOST, PORT)

func _connect_after_timeout(timeout: float) -> void:
	yield(get_tree().create_timer(timeout), "timeout") # Delay for timeout
	_client.connect_to_host(HOST, PORT)

func _handle_client_connected() -> void:
	print("Client connected to server.")

func _handle_client_data(data: PoolByteArray) -> void:
	print("Client data: ", data.get_string_from_utf8())
	var message: PoolByteArray = [97, 99, 107] # Bytes for "ack" in ASCII
	_client.send(message)

func _handle_client_disconnected() -> void:
	print("Client disconnected from server.")
	_connect_after_timeout(RECONNECT_TIMEOUT) # Try to reconnect after 3 seconds

func _handle_client_error() -> void:
	print("Client error.")
	_connect_after_timeout(RECONNECT_TIMEOUT) # Try to reconnect after 3 seconds

If you already have a server to connect to, then you can run your scene and you should see “Client connected to server.” printed on a successful connection. Make sure you change the host and port to that of your server!

If you do not have a server, then you can create a simple server to test against using Node.js. Make sure you have node.js installed on your system and then create a new file called server.js with the following contents.

const tls = require('tls');
const fs = require('fs');

const options = {
   key: fs.readFileSync('test-key.pem'),
   cert: fs.readFileSync('test-cert.pem'),
};

let server = tls.createServer(options, (socket) => {
	socket.on('data', (data) => {
		console.log("Received: " + data);
	});
	console.log("Accepted connection.");
	socket.write("Hello from the server!\n");
}).listen(5000, () => console.log("Listening on 5000."));

Notice the options for passing in the server’s key and certificate. The generation of these certificates is out of the scope of this tutorial, but if you do not already have them you can generate them with the process described here.

Start the server with the node command by running node server.js. Then run your main scene. If the connection was successful, then you should get the following output.

Connecting to 127.0.0.1:5000
Connected to host.
Client connected to server.
available bytes: 23
Client data: Hello from the server!

Congratulations, now you have a client running in Godot that will connect to an external server using a SSL connection!