PHP WebSocket Server @ github

Introduction

This document describes the core of a websocket server written in PHP. This server actualy runs on the same Linux System (Ubuntu) as the webserver apache2 is running. This webserver serves webcontent using the https protocoll. The certificate in this case is installed using certbot/letsencryt, just to mention it.

If your setup is similar but does not use the https protocoll you can still use the socket server described here, as it takes care of this.

These are the major building blocks that will be explained in detail.

The other parts of such a server have to deal with book keeping that is knowing everything about the state of the clients.

Finaly there is also code to interpret and react to messages but this highly dependend on the application the server has to support.

To make things more interesting there is also some separate code to actualy ignite the server, register some applications with it and finaly make it start and work. This code is highly individuel, hence you probably need to adapt it to your needs. For all the code please have a look at the github repository.

Lets have a look at the three major building blocks.

Prepare for connection

What is needed upfront

Address and Port

Sounds trivial but we need to specify an address and a port where the server should listen. We use an include file called adressPort.inc.php to keep variables $Address and $Port.
               
/*
 * **************************
 * include address and port like:
 * $Address=[ssl:// | tcp://]server.at.com
 * Use ssl:// if your server must use secure protocol
 * otherwise use tcp://
 *
 * $Port=number
 * **************************
 */
//$Address = 'ssl://your.server.net';
//$Address = 'tcp://your.server.net';
$Address = 'ssl://myserver.myprovider.net';
$Port = 8091;
            
There is a method in the server class, if called like $this->isSecure($Address)
it returns true if ssl: is found as a prefix of the given address, otherwise false.

When that method returns we have just the host part in $Address left.

SSL context options: keyAndCertFile and pathToCert

If you do not need to setup communication via ssl then you can ommit this step and leave the variables empty

Because my webclients are emitted by the webserver using https and these webclients want to use websocket they have to use wss instead of ws if they want to connect and talk with the server.

The implication for the server is that it is forced now to use the ssl protocol instead of tcp.

I am using the PHP stream_socket_server function to be able to use use both ssl or tcp

As my webclients connect using wss I have to setup communication via ssl you need a create a context and give it to the above fucntion.

The variables $keyAndCertFile and $pathToCert come from an include file called certPath.inc.php
They are requires for the SSL context options.

 
$keyAndCertFile = '/etc/letsencrypt/live/myserver.myprovider.net/certKey.pem';
$pathToCert = '/etc/letsencrypt/live/myserver.myprovider.net';
            
the file certKey.pem has to be created because it does not exist but is required by PHP and it has to include the certificate and the private key. To create this file use the commands below.
cd /etc/letsencrypt/live/myserver.myprovider.net
openssl pkcs12 -export -in cert.pem -inkey privkey.pem -out tmp.p12
openssl pkcs12 -in tmp.p12 -nodes -out certKey.pem
            

All together now

Here is the constructor for the server, from the class webSocketServer.php
Just some code fragments ...
Variables from the includes above are passed as parameters
function __construct($Address, $Port, $logger, $keyAndCertFile = '', $pathToCert = '') {

    $errno = 0;
    $errstr = '';
    $this->logging = $logger;
     $usingSSL = '';
    $context = stream_context_create();
    if ($this->isSecure($Address)) {
        stream_context_set_option($context, 'ssl', 'local_cert', $keyAndCertFile);
        stream_context_set_option($context, 'ssl', 'capth', $pathToCert);
        stream_context_set_option($context, 'ssl', 'allow_self_signed', true);
        stream_context_set_option($context, 'ssl', 'verify_peer', false);
        $usingSSL = "using SSL";
    }
    $this->socketMaster =  stream_socket_server("$Address:$Port", $errno, $errstr, 
                                    STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context);

    $this->Log("Server initialized on " . PHP_OS . "  $Address:$Port $usingSSL");
    if (!$socket) {
        $this->Log("Error $errno creating stream: $errstr", true);
        exit;
    }
    $this->serveros = PHP_OS;
    $this->Sockets["m"] = $socket;
    $this->socketMaster = $socket;

    error_reporting($this->errorReport);
    set_time_limit($this->timeLimit);
    if ($this->implicitFlush) {
        ob_implicit_flush();
    }
}

/* ... some more code ....  */

                
Now you have created a master socket to listen for connections and more.

From here follows standard socket programming for a server, that means listening for connections, accepting connections managing connections, reading from and writing to connect clients.
However, before you can actualy use a connection on a websocket you have to perfoma a handshake with those clients.
You can have a look at this section of code here

public function Start() {

    $this->Log("Starting server...");
    foreach ($this->allApps as $appName => $class) {
        $this->Log("Registered resource : $appName");
    }
    $a = true;
    $nulll = NULL;
    while ($a) {
        $socketArrayRead = $this->Sockets;
        $socketArrayWrite = $socketArrayExceptions = NULL;
        /*
        Here we sit and wait for connections or messages from clients.
        */
        stream_select($socketArrayRead, $socketArrayWrite, $socketArrayExceptions, $nulll);
        foreach ($socketArrayRead as $Socket) {
            $SocketID = intval($Socket);
            if ($Socket === $this->socketMaster) {
                $Client = stream_socket_accept($Socket);
                if (!is_resource($Client)) {
                    $this->Log("$SocketID, Connection could not be established");
                    continue;
                } else {
                    /*
                    A new connection , save the client data.
                    */
                    $this->addClient($Client);
                    $this->onOpening($SocketID);
                }
            } else {
                $Client = $this->Clients[$SocketID];
                if ($Client->Handshake == false) {
                    /*
                    Perform the handshake now.
                    */
                    $dataBuffer = fread($Socket, $this->bufferLength);
                    if ($this->Handshake($Socket, $dataBuffer)) {
                        if ($this->Clients[$SocketID]->app === NULL) {
                            $this->Close($Socket);
                            $this->Log('Application incomplete');
                        } else {
                            /*
                            telling the client we are ready to receiver messages    
                            */
                            $this->Log("Telling Client to start on  #$SocketID");
                            $msg = (object) Array('opcode' => 'ready', 'os' => $this->serveros);
                            $this->Write($SocketID, json_encode($msg));
                            $this->onOpen($SocketID); // route to client app->onOpen
                        }
                    }
                } else {
                    /*
                    A client sends a message
                    */
                    $dataBuffer = fread($Socket, $this->bufferLength);
                    if ($dataBuffer === false) {
                        $this->Close($Socket);
                    } else if (strlen($dataBuffer) == 0) {
                        $this->onError($SocketID, "Client disconnected - TCP connection lost");
                        $SocketID = $this->Close($Socket);
                    } else {
                        /*
                         Have a closer look at what the client sent
                         */
                        $this->Read($SocketID, $dataBuffer);
                    }
                }
            }
        }
    }
}
                    

Da Händshake ; give me five

Without a proper handshake between the webclint and server no communication will take place.

There might be more elegant implementaions for the handshake out there, but this one works for me.

           
protected function Handshake($Socket, $Buffer) {

    $addHeader = [];
    $SocketID = intval($Socket);
    $Headers = [];
    $reqResource = [];
    $Lines = explode("\n", $Buffer);
    /*
    This block of code is looking at the first line send from a client. 
    If this comes from my php client
    we just accept the connection an return.

This might be or is a security issue but ... */ if ($Lines[0] == "php process") { $this->Log('Handshake:' . $Buffer); $this->Clients[$SocketID]->Headers = 'tcp'; $this->Clients[$SocketID]->Handshake = true; preg_match("/GET (.*) HTTP/i", $Buffer, $reqResource); $Headers['get'] = trim($reqResource[1]); $this->Clients[$SocketID]->app = $this->allApps[$Headers['get']]; return true; } /* Here we enter the real handshake. First we dissect every line received from the client and collect what we found. We convert the keys to lowercase and save the value along with the key. This makes us independent of the sequence in wich the statements are received. */ $this->Log('Handshake: webClient'); foreach ($Lines as $Line) { if (strpos($Line, ":") !== false) { $Header = explode(":", $Line, 2); $Headers[strtolower(trim($Header[0]))] = trim($Header[1]); } else if (stripos($Line, "get ") !== false) { preg_match("/GET (.*) HTTP/i", $Buffer, $reqResource); $Headers['get'] = trim($reqResource[1]); } } /* Here we look if everything required by RFC6455 has been sent. In case something is missing we collect the related responds for the client. */ if (!isset($Headers['host']) || !isset($Headers['sec-websocket-key']) || (!isset($Headers['upgrade']) || strtolower($Headers['upgrade']) != 'websocket') ||(!isset($Headers['connection']) || strpos(strtolower($Headers['connection']), 'upgrade') === FALSE)) { $addHeader[] = "HTTP/1.1 400 Bad Request"; } if (!isset($Headers['sec-websocket-version']) || strtolower($Headers['sec-websocket-version']) != 13) { $addHeader[] = "HTTP/1.1 426 Upgrade Required\r\nSec-WebSocketVersion: 13"; } if (!isset($Headers['get'])) { $addHeader[] = "HTTP/1.1 405 Method Not Allowed\r\n\r\n"; } if (count($addHeader) > 0) { $addh = implode("\r\n", $addHeader); fwrite($Socket, $addh, strlen($addh)); $this->onError($SocketID, "Handshake aborted - [" . trim($addh) . "]"); $this->Close($Socket); return false; } /* All is fine ! Now send back the expected resonds. */ $Token = ""; $sah1 = sha1($Headers['sec-websocket-key'] . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); for ($i = 0; $i < 20; $i++) { $Token .= chr(hexdec(substr($sah1, $i * 2, 2))); } $Token = base64_encode($Token) . "\r\n"; $addHeaderOk = "HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n Connection: Upgrade\r\n Sec-WebSocket-Accept: $Token\r\n"; fwrite($Socket, $addHeaderOk, strlen($addHeaderOk)); /* We save the current state for our new client. */ $this->Clients[$SocketID]->Headers = 'websocket'; $this->Clients[$SocketID]->Handshake = true; $this->Clients[$SocketID]->app = $this->allApps[$Headers['get']]; return true; }

Encode,write / Decode,read messages

Now that the handshake is done, we can read from and write messages to our websockets but we have to encode outgoing messages and decode incoming messages according to the specification in RFC6455

To be honest I am realy happy that some kind folks have published code for both encoding end decoding messages. Yo can look at the code here .

In the server class exist two methods that make use of the encoding and decoding functions. If we have to handle traffic with a websocket we do the encodeing/ decoding, else we have just simple write.
public function Write($SocketID, $message) {
    if ($this->Clients[$SocketID]->Headers === 'websocket') {
        $message = $this->Encode($message);
    }
    return fwrite($this->Sockets[$SocketID], $message, strlen($message));
}                    
                
Reading from websocket/socket is done with this.
public function Read($SocketID, $message) {
    $client = $this->Clients[$SocketID];
    if ($client->Headers === 'websocket') {
        $message = $this->Decode($message); // set also $this->opcode 
        if ($this->opcode == 10) { //pong
            /*
            I have seen this PONG comming only from IE and Edge, just ignore it
            */
            $this->opcode = 1; // text frame 
            $this->log("Unsolicited Pong frame received from socket #$SocketID"); // just ignore
            return;
        }
        if ($this->opcode == 8) { //Connection Close Frame             
            $this->opcode = 1; // text frame 
            $this->log("Connection Close frame received from socket #$SocketID");
            $this->Close($SocketID);
            return;
        }
    }
    /*
    Client expects this message from the sever
        before sending another message,so release the client
    */ 
    $this->Write($SocketID, json_encode((object) ['opcode' => 'next']));
    /*
    Look if client has send a server command like bufferON/bufferOFF   
    */
    if ($this->serverCommand($client, $message)) {
        return;
    }

    if ($client->bufferON) {
        /*
        client sends a very long message in chunks. collect hese. 
        */
        $client->buffer[] = $message;
        return;
    }
    /* passs the messaeg now to the client app->onData  */
    $this->onData($SocketID, $message);
}
                

Credits

Without the work of many other people, this server would probably not exist. Thank you !