Start
About
Blog
Contact
Résumé
Login
Replay Splash
Games
Clones N Barrels
Subspace
Shutdown
Build a WebSocket Server in PHP with an SSL Certificate from LetsEncrypt.
Bring to front
View
Edit
Moderate
Delete
05:46:52 PM

Build a WebSocket Server in PHP with an SSL Certificate from LetsEncrypt.

Written by Sean Morris on 2018-07-23

All code in this article is released under the Apache 2.0 licence.

WebSockets are awesome, but if you don't encrypt the connection, its wide open to a Man in the Middle attack. For HTTP(s), we simply encrypt our traffic via ssl before transport, and we can do the same for websockets just as easily.

For this example, we'll build a basic websocket relay server that will send messages from any client to all clients.

Both Apache and nginx provide SSL passthrough interfaces, but wheres the fun in that?

Please note: This guide assumes...

  • your server is running Linux.
  • you've already set SSL up with certbot.
  • you've got ssh/terminal access to you server.
  • you've got root access to your server.

Locate your certificate files

First, run the following command:

Replace example.com with your domain

$ ls /etc/letsencrypt/live/example.com/

If you get the following output, good! That means your permissions are set correctly.

ls: cannot access '/etc/letsencrypt/live/example.com': Permission denied

Re-run the command as root to ensure the files actually exist:

$ sudo ls /etc/letsencrypt/live/example.com
[sudo] password for user: 

Sorry, try again.
[sudo] password for user: 

cert.pem  chain.pem  fullchain.pem  privkey.pem  README

Keep your keys/server secure

So our keys are only accessible by the root user. This is not ideal, since we don't want our new server running as the root user. God forbid one day there is an exploit that allows users to execute arbitrary code on PHP 7. We could mitigate the potential damage caused if the service is sandboxed to a user, but if the script is running as root, it could do literally anything to the server.

The first step toward ensuring we're secure is creating a user group called ssl-cert. Debian based distros come with this group by default, so lets check if we need to create it by running groups. If you see ssl-cert in the list, we don't need to create it.

$ groups
... ssl-cert ...

If you don't see it in the list, create the group with the following command.

$ sudo groupadd ssl-cert

Add your current user to the ssl-cert group with the usermod command.

$ sudo usermod -a -G ssl-cert $USER

Use the chgrp command to change the ssl cert archive & live directory group to ssl-cert. Then use chmod to allow users with that group to read the files.

$ sudo chgrp ssl-cert /etc/letsencrypt/archive/
$ sudo chgrp ssl-cert /etc/letsencrypt/live/
$ sudo chmod g+rX /etc/letsencrypt/archive/
$ sudo chmod g+rX /etc/letsencrypt/live/

Coming full circle, run the following command:

Replace example.com with your domain

$ ls /etc/letsencrypt/live/example.com/

If you get the following output, good! That means your permissions are set correctly.

cert.pem  chain.pem  fullchain.pem  privkey.pem  README

Set up the socket

The two files we need to pay attention to are privkey.pem and chain.pem. privkey.pem is our private key, while chain.pem is the "chain certificate." That file authenticates each authority back to the root issuer, so it just as important as our private key if we want things to work correctly.

You generally won't need to worry about a $passphrase if you're using LetsEncrypt. I personally have not observed a facility to set one, but I haven't looked for it much either. If you've figured that out, enter it here, if not (like me) leave it blank.

Those three bits of information allow us to set up the $context variable, and then open the socket. With the code below, the socket will listen for connections from any address on port 9999.

<?php
$keyFile    = '/home/sean/ssl_test/privkey.pem';
$chainFile  = '/home/sean/ssl_test/chain.pem';
$passphrase = '';
$address    = '0.0.0.0:9999';

$context = stream_context_create([
	'ssl'=>[
		'local_cert'    => $chainFile
		, 'local_pk'    => $keyFile
		, 'passphrase'  => $passphrase
		, 'verify_peer' => FALSE
	]
]);

$socket = stream_socket_server(
	$address
	, $errorNumber
	, $errorString
	, STREAM_SERVER_BIND|STREAM_SERVER_LISTEN
	, $context
);

We're also seting verify_peer to false, since we don't expect our random users to have SSL certificates of their own. More advanced use cases may require this to be set to true, but that's beyond the scope of this article.

See the php documentation for more information about stream_context_create, stream_socket_server or the options used to create the $context.

Listen for connections

We can use stream_socket_accept to check for any new connections on the socket. A timeout of 0 is used here so that if no new connections are available at any given time, the program can immediately proceed to other actions.

If we've picked up a new connection, we'll want to enable crypto at this point. For now, we need to ensure the socket is blocking.

Once thats done we'll try to read some header data from the new connection. Among the headers we should find the Sec-WebSocket-Key. We'll parse this value out and send it back to the client in the Switching Protocols header.

if($newStream = stream_socket_accept($socket, 0))
{
	stream_set_blocking($newStream, TRUE);

	stream_socket_enable_crypto(
		$newStream
		, TRUE
		, STREAM_CRYPTO_METHOD_SSLv23_SERVER
	);

	$incomingHeaders = fread($newStream, 2**16);

	if(preg_match('#^Sec-WebSocket-Key: (\S+)#mi', $incomingHeaders, $match))
	{
		stream_set_blocking($newStream, FALSE);

		fwrite(
			$newStream
			, "HTTP/1.1 101 Switching Protocols\r\n"
				. "Upgrade: websocket\r\n"
				. "Connection: Upgrade\r\n"
				. "Sec-WebSocket-Accept: " . base64_encode(
					sha1(
						$match[1] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
						, TRUE
					)
				)
				. "\r\n\r\n"
		);

		fwrite(STDERR, 'Accepted client!');

		$clients[] = $newStream;
	}
	else
	{
		stream_socket_shutdown($newStream, STREAM_SHUT_RDWR);
	}
}

Get data from clients

We'll need to do some minor decoding before we can use the messages we read from our clients.

For each client, we'll read a maximum bytes 2¹⁶, bytes (65,536 bytes, or 64kb) from the client. The first byte, ord($message[0]) tells us a few things. By checking if its value is greater than 128, we're really looking at the first bit of the byte. This bit tells us whether the data we've received is the last frame of the message. If its 1, the data represents the last frame, if its 0, then we'd need to expect more frames to complete the message. Multi-frame messages are beyond the scope of this article, so we'll just drop any frames where this bit is 0.

Once we've done that we'll subtract 128 from this byte to get the number that represents its type. 0x1 represents a text-based message. There are other message types, such as 0x2 "binary", 0x8 "close", 0x9 "ping", 0xA "pong". We'll be ignoring everything but 0x1 "text" and 0x8 "close" for now.

If the type byte is 0x1 "text", we'll parse out the data and the masks based on the length. The offset of our 4 byte mask changes based on the value of the length byte. See how in the if/else block below.

Once we have that, we'll XOR the each of the remaning bytes with the byte in the mask that cooresponds to the 4th modulus of its position in the stream ($i) (since we have 4 mask bytes).

If the type byte is 0x8 "close", we'll just close the connection.

$messages = [];

foreach($clients as $i => $client)
{
	if(!$client)
	{
		continue;
	}

	while($message = fread($client, 2**16))
	{
		fwrite(STDERR, 'Accepted message!');

		$type = ord($message[0]);

		if($type > 128)
		{
			$type -= 128;
		}

		if($type == 0x1)
		{
			$length = ord($message[1]);

			if($length <= 126)
			{
				$mask = substr($message, 4, 4);
				$data = substr($message, 8);
			}
			else if($length == 127)
			{
				$mask = substr($message, 10, 4);
				$data = substr($message, 14);
			}
			else
			{
				$mask = substr($message, 2, 4);
				$data = substr($message, 6);
			}

			$decoded = '';

			for ($i = 0; $i < strlen($data); ++$i)
			{
				$decoded .= $data[$i] ^ $mask[$i%4];
			}

			$messages[] = $decoded;

			fwrite(STDERR, $decoded);
		}
		else if ($type == 0x8)
		{
			stream_socket_shutdown($client, STREAM_SHUT_RDWR);

			$clients[$i] = FALSE;
		}
	}
}

Send data to clients

We need to essentially do the inverse to each message on the way out. First we set the type byte to 0x1 "text" then add 128 to flip the "final frame" bit to 1. We then pack the type and the length, into the first few bytes. Once the header is created, we tack the $message data on and send it to each client.

Note: We're essentially doing the reverse of the decoding step above. We could in theory relay the messages without decoding/encoding them, but then the script would not lend itself to modifications in which the server can interpret and react to messages.

foreach($messages as $message)
{
	$length   = strlen($message);

	$typeByte = 0x1;

	$typeByte += 128;

	if($length < 126)
	{
		$encoded = pack('CC', $typeByte, $length) . $message;
	}
	else if($length < 65536)
	{
		$encoded = pack('CCn', $typeByte, 126, $length) . $message;
	}
	else
	{
		$encoded = pack('CCNN', $typeByte, 127, 0, $length) . $message;
	}

	foreach($clients as $client)
	{
		if(!$client)
		{
			continue;
		}

		fwrite($client, $encoded);

		fwrite(STDERR, $encoded);
	}
}

Put it all together

This is the fun part. We'll be looping inside while(true) to keep the thread alive as long as we need. We'll first open the socket, and start by checking for any new clients, then look for new messages. If we've received any messages, we'll send them to all clients.

<?php

// Set up the socket

$keyFile    = '/etc/letsencrypt/live/example.com/privkey.pem';
$chainFile  = '/etc/letsencrypt/live/example.com/chain.pem';

$keyFile    = '/home/sean/ssl_test/privkey.pem';
$chainFile  = '/home/sean/ssl_test/chain.pem';
$passphrase = '';
$address    = '0.0.0.0:9999';

$context = stream_context_create([
	'ssl'=>[
		'local_cert'    => $chainFile
		, 'local_pk'    => $keyFile
		, 'passphrase'  => $passphrase
		, 'verify_peer' => FALSE
	]
]);

$socket = stream_socket_server(
	$address
	, $errorNumber
	, $errorString
	, STREAM_SERVER_BIND|STREAM_SERVER_LISTEN
	, $context
);

// Set an error handler

$errorHandler = set_error_handler(
	function($errCode, $message, $file, $line, $context) use(&$errorHandler) {
		if(substr($message, -9) == 'timed out')
		{
			return;
		}

		fwrite(STDERR, sprintf(
			"[%d] '%s' in %s:%d\n"
			, $errCode
			, $message
			, $file
			, $line
		));

		if($errorHandler)
		{
			$errorHandler($errCode, $message, $file, $line, $context);
		}
	}
);

$clients = [];

while(TRUE)
{
	// Listen for connections

	if($newStream = stream_socket_accept($socket, 0))
	{
		stream_set_blocking($newStream, TRUE);

		stream_socket_enable_crypto(
			$newStream
			, TRUE
			, STREAM_CRYPTO_METHOD_SSLv23_SERVER
		);

		$incomingHeaders = fread($newStream, 2**16);

		if(preg_match('#^Sec-WebSocket-Key: (\S+)#mi', $incomingHeaders, $match))
		{
			stream_set_blocking($newStream, FALSE);

			fwrite(
				$newStream
				, "HTTP/1.1 101 Switching Protocols\r\n"
					. "Upgrade: websocket\r\n"
					. "Connection: Upgrade\r\n"
					. "Sec-WebSocket-Accept: " . base64_encode(
						sha1(
							$match[1] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
							, TRUE
						)
					)
					. "\r\n\r\n"
			);

			fwrite(STDERR, 'Accepted client!');

			$clients[] = $newStream;
		}
		else
		{
			stream_socket_shutdown($newStream, STREAM_SHUT_RDWR);
		}
	}

	// Get data from clients

	$messages = [];

	foreach($clients as $i => $client)
	{
		if(!$client)
		{
			continue;
		}

		while($message = fread($client, 2**16))
		{
			fwrite(STDERR, 'Accepted message!');

			$type = ord($message[0]);

			if($type > 128)
			{
				$type -= 128;
			}

			if($type == 0x1)
			{
				$length = ord($message[1]);

				if($length <= 126)
				{
					$mask = substr($message, 4, 4);
					$data = substr($message, 8);
				}
				else if($length == 127)
				{
					$mask = substr($message, 10, 4);
					$data = substr($message, 14);
				}
				else
				{
					$mask = substr($message, 2, 4);
					$data = substr($message, 6);
				}

				$decoded = '';

				for ($i = 0; $i < strlen($data); ++$i)
				{
					$decoded .= $data[$i] ^ $mask[$i%4];
				}

				$messages[] = $decoded;

				fwrite(STDERR, $decoded);
			}
			else if ($type == 0x8)
			{
				stream_socket_shutdown($client, STREAM_SHUT_RDWR);

				$clients[$i] = FALSE;
			}
		}
	}

	// Send data to clients

	foreach($messages as $message)
	{
		$length   = strlen($message);

		$typeByte = 0x1;

		$typeByte += 128;

		if($length < 126)
		{
			$encoded = pack('CC', $typeByte, $length) . $message;
		}
		else if($length < 65536)
		{
			$encoded = pack('CCn', $typeByte, 126, $length) . $message;
		}
		else
		{
			$encoded = pack('CCNN', $typeByte, 127, 0, $length) . $message;
		}

		foreach($clients as $client)
		{
			if(!$client)
			{
				continue;
			}

			fwrite($client, $encoded);

			fwrite(STDERR, $encoded);
		}
	}
}

Testing it

Run the server via the command line. This is NOT a production ready strategy, but it will allow us to verify everything is working correctly.

$ php server.php

Once the above command is running and blocking your terminal, open chrome and run the following in the debugging console to connect to our socket.

let ws = new WebSocket('wss://example.com:9999');

ws.addEventListener('message', event => {
	console.log('Message received: ' + event.data);
});

Once we're connected, call ws.send to send a message to the server. If everything is working, we should receieve the message back as a console log entry.

ws.send('Hello, world!');
Message received: Hello, world!

Running it as a service

Personally there are two strategies I'd use to run this in a public facing environtment: Supervisor and Docker. Supervisor is a bit simpler than Docker, but this project is a great way to break into learning Docker. Only one of the two is necesary.

Supervisor

If you haven't already, install supervisor.

$ sudo apt install supervisor

We'll need a system user to isolate the process. Use the useradd -r command to add the user and the usermod command to add it to the ssl-cert group we worked with earlier.

$ sudo useradd -r socket
$ sudo usermod -a -G ssl-cert socket

Once thats done, set up the /etc/supervisor/conf.d/socket.conf file.

[program:socketServer]
process_name = %(program_name)s_process_%(process_num)02d
command=php server.php
directory=/home/user/ssl_socket_tutorial
autorestart=yes
autostart=yes
user=socket
startsecs=1
numprocs=1

Now, we simply restart supervisor and repeat the testing section above:

sudo service restart supervisor

Docker

If you haven't already, install docker & docker compose.

We'll first need to allow the docker user access to our SSL files.

$ sudo usermod -a -G ssl-cert docker

Create a Dockerfile that will set up the environment in which the service will run. We'll call it socket.dockerfile for use in the docker-compose.yml file below.

Dockerfile

FROM php:7.2.8-cli-alpine3.7

RUN mkdir /app

COPY ./server.php /app

CMD php /app/server.php

docker-compose.yml

Create a docker-compose.yml to allow us to instantiate the containers and stitch in the key directories.

Replace example.com with your domain

version: '3.3'

services:
  socket:
    build:
      dockerfile: socket.dockerfile
      context: ./
    restart: always
    ports:
      - "9999:9999"
    volumes:
      - /etc/letsencrypt/live/example.com/:/etc/letsencrypt/live/example.com/

Build the docker containers

$ docker-compose build

Run the docker containers as a test.

$ docker-compose up

Use ctrl+c to stop the above instance, and then start is as a daemon with -d so you can log out of your ssh and/or terminal session without killing it:

$ docker-compose up -d

Extra Bits

You may have noticed the below block of code in the "Putting it all together" section above.

If you're like me, you'll have php set up to die on notice, so its rarely going to do something we dont expect. In this case, the warnings generated when stream_socket_accept fails to find a client are completely unacceptable.

We can catch this error and suppress it while still passing everything else up to any existing error handlers. You'll notice the first thing we do is set $errorHandler to the return value of set_error_handler. This is because that function will return the exsting error handler before changing it. If we pass this variable into the namespace of the new error handler (as a reference inside use(&$errorHandler)), we can call the existing error handler after checking the $message to see if the error is relevant or not.

In this example, we'll also print any errors/warnings/notices to the terminal via STDERR.

$errorHandler = set_error_handler(
	function($errCode, $message, $file, $line, $context) use(&$errorHandler) {
		if(substr($message, -9) == 'timed out')
		{
			return;
		}

		fwrite(STDERR, sprintf(
			"[%d] '%s' in %s:%d\n"
			, $errCode
			, $message
			, $file
			, $line
		));

		if($errorHandler)
		{
			$errorHandler($errCode, $message, $file, $line, $context);
		}
	}
);
links go here
Back to top