Implementing a WebSocket Server with PHP

Author: Heinz Schweitzer, December 2020 ; still in draft mode, may move to other location

alt

Preface

This document is about a real implementation of a WebSocket server written in vanilla PHP. It describes the inner working and structure of the server as well as code for web clients and PHP clients to actually use the server.

Furthermore it will go a step beyond the simple coding examples of just opening a WebSocket connection and sending a message or receiving a message. There is way more analysis and design associated with using WebSocket as expected at first sight. Hopefully this document will help you understand the implemented infrastructure supporting web sockets on the client and server side.

The development started in 2016 to satisfy the need to inform users of a web application about activities of other users and to inform about progress made in PHP backend scripts triggered by regular asynchron XHTTPrequests.

Who is this document for ?

It is for developers, software architects, project managers, not sure yet if and how web sockets can enrich their work and how it improves user experience. I presume the reader has some knowledge of HTML, JavaScript and PHP. Coding in these languages and reading code should not be a problem. In other words I assume the reader had a look at the WebSocket API for JavaScript, likes it, but got stuck because a lack of a Server to talk to.

Requirements

Have access to https://github.com/napengam/phpWebSocketServer you will find all the sources there. This is it. There are no other dependencies.

Recommendations

Why using WebSocket ?

From https://en.wikipedia.org/wiki/WebSocket
WebSocket is a computer communications protocol, providing full-duplex communication channels over a single TCP connection. The WebSocket protocol was standardized by the IETF as RFC 6455 in 2011, and the WebSocket API in Web IDL is being standardized by the W3C.
The magic here is that with web sockets you can from a web page, at any time send messages to a server or receive at any time messages from that server.

Some real life examples

EMAS

A web application working since 2017 in the intranet of a library the WLB W├╝rtembergische Landesbibliothek Stuttgart .

An extreme simplified explanation what this application is used for is, ordering new books for the inventory of the library and customers. One source of information about new books is a database regularly filled with information about new books published.

Chances are that more than one expert is viewing the same set of new books. One requirement of that project is to visually indicate to all viewers what books they can work with (buying) and where they should be aware that others might work with that book.

To satisfy this requirement, every active web application sends, as soon as a page of new book titles is rendered in the client, all book ids via WebSocket to the WebSocket server, telling the server to lock these books. The server does its book keeping and tells the client what books are already locked by others by sending these book ids back to the client. The book keeping on the server side is done entirely in memory, no database is used here.

If the client receives through WebSocket book ids that can't be locked it dims these book titles in the GUI.

This is just a fraction of what is going forth and back between the client and the server, but I think you get the idea.

Next, if the client triggers a complex query, send with an XHTTP-Request to the PHP backend, this PHP process connects also to the WebSocket server to report any progress back to the client that triggered the request.

As the queries and processing the result can run for several seconds this will keep the user informed about the real progress. This is more convenient then displaying only an animated gif.

RVK-Browser

This project, yet not online, allows a user to query the inventory of the WLB using the RVK notation.
As the inventory is about 5 million books, the query within the RVK notation, may run from seconds to several minutes. As long as the database is working you can only report in intervals, the time in seconds the query already worked in the database back to the user. If a certain threshold is crossed the user is offered to abort the query and start something new.

Here again web sockets and the server are at work to manage the flow of messages between PHP scripts, a sql monitor and the client.

The benefit for user is that he knows the query is still alive and working and that he can abort the query.

WLBRISERVA

A reservation system for workspaces is another project inside the WLB.
This one allows registered users of the WLB to make reservations for workspaces over the internet. Here web sockets are mainly used to give the user feedback about what is happening in the background like reading a calendar or looking for free slots and the like. Here and in the other examples mentioned, such feedback is given in pseudo modal information dialogs blocking the user interface until the XHTTP request comes back.

Conclusion

The given examples demonstrate the benefits you can expect by using web sockets. However web sockets and a WebSocket server just provide a channel you can talk and listen to.

The real power though unfolds with the messages you send and receive, meaning how the messages are structured and how they fit into the application.

High level overview

Before diving into all the gory details of the implementation here is an overview to give you more context for the basic components needed having web sockets to work for your needs.

Messages

Messages are the ingredients that brings the entire WebSocket realm to live.

As a message and data interchange format we use JSON. This format can transport a variety of data structures including arrays. Functions to encode and decode JSON are well known and documented for JavaScript and PHP.

Common to all messages and expected to exist, are two attributes:

{'opcode'= 'code', 'message'=messageValue }

A messageValue can range from a simple string up to any other JSON structure. This is highly dependent on what you need or want to achieve.

Opcodes are just strings.
Here are opcodes already in use.

Next we have messages that are just short strings not encoded as JSON.
These are used to turn on or off some server internal functionality. These messages are hidden inside the provided code for JavaScript clients and PHP clients.

As of now we have two such control strings:


NOTE:

From a client , sending very long messages in chunks, offers more control on how much data can be sent. If you want to turn this off and receive long messages (>8k) in one read look for and uncomment this statetment in webSocketServer.php

stream_set_read_buffer($Socket, 0); // no buffering hgs 01.05.2021

In the code for the clients, web and tcp , just set variable chunkSize=0, to turn off sending long messages in chunks.




Connection

Everything starts here.

alt

The flow of messages

What you see here is the interplay between the web client using the provided JavaScript code and the server. The main point is that after the handshake according RFC6455 is done, a defined dialog takes place between the client and the server to bring the connection to a steady state.

NOTE:

This dialog is not covered in any RFC, it is just invented by me. Feel free to change it in any way.

The client having received the next message from the server is now enabled to send another message.

This is the way I control the flow of messages from any client to the server.

After every message clients have transmitted, they need the NEXT message from the server , as an acknowledgement, before they send another message. The server can send a message at any time.

Why?

As mentioned, the connection is now in a steady state and the server knows about the client via the uuid and the associated socket and knows the application class where to route all client messages to.

Managing connections

For managing client connections, the stores allApps, clients sockets and clientIPs are used.
If these would be implemented as tables in a sql database a crude entity relationship diagram would look like the one below.

alt

The relation between these is as follows.
From a socket resource returned by stream_socket_* functions and ID is derived
using intval(socket).

This ID is used as an index into the table socket and client.
In the table client we store all other needed info about the client like the appName transmitted with the handshake, web and the UUID is transmitted within the dialog after the handshake.

The store allApps is prepared before the server is started, by some startup code.
In fact it is an array of pointers to instances of classes representing the applications by implementing required methods to handle messages from client. Read more about this in section booting the Server

With the store clientIP we track, for websockets, how may connections from the clientIP are made. If this exceeds a given threshhold the connection is closed and a message is logged. The default for the threshhold is set to 0 this means unlimited.

NOTE:

It is nowhere written that client management and message routing, has to be done like this.
Again there is no RFC or similar, that covers or recommends an architecture like the one here. Feel free to change this in any way.

Feedback

Now that the client is connected it can start to work by triggering a PHP back end script.

alt

This is a common scenario for web a application. Triggering some backend script because of some event fired by a user through the GUI, or nowadays called the UX.

The point here is that the web application is passing its UUID, among other parameters, to the backend script. The PHP backend script connects with the server, sending the UUID, message and opcode FEEDBACK to the server.

With UUDI the server finds the client, the associated socket and the application responsible for this client. With this the message can be transmitted back to the web application.

NOTE:

Connecting a PHP script to the server does not require a handshake or encoding or decoding messages because this is just a simple socket connection. The server handles this connection the same as a connection from a web client.

Broadcast

Here we see the web client using a broadcast

alt

In my applications broadcast messages are used to announce events or state changes to all other web applications, mainly to keep all players in sync. What the recipients do with the message and the data coming with it, is dictated by the application.

Be carefull

Sending messages from a web-client to a websocket server is not without risk. As any mischievous user can open the developer tools of the browser, locate the related code, and send, or manipulate, whatever messages, using the debugger and console. This might have detrimental effects on the application.

To my knowledge there is as yet no way to detect this. If I am wrong let me know !

NOTE:

Therefore I recommend, as a web client, to just read from a websocket, unless you run the web applciation in an intranet where you, hopefully, can trust your users.

The number of connections from one IP/Host can also be limited to avoid an overflow of connections bringing the server probably down. The default for this value is 'unlimited'.

Connections using simple 'tcp' are only allowed from the host the server is running. This restriction is set in the constructor method for the server.

Search also the web for 'websocket vulnerabilities' , this will give you more information

Implementation

Sources on github https://github.com/napengam/phpWebSocketServer

Credits

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

phpWebSocketServer/server/RFC6455.php

protected function Handshake($Socket, $Buffer) {

        $errorResponds = [];
        $SocketID = intval($Socket);
        $Headers = [];
        $reqResource = [];
        $Lines = explode("\n", $Buffer);

        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]);
            if (isset($this->allApps[$Headers['get']])) {
                $this->Clients[$SocketID]->app = $this->allApps[$Headers['get']];
            }
            return true;
        }
        $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]);
            }
        }
  
        if (!isset($Headers['host']) || !isset($Headers['origin']) ||
                !isset($Headers['sec-websocket-key']) ||
                (!isset($Headers['upgrade']) || strtolower($Headers['upgrade']) != 'websocket') ||
                (!isset($Headers['connection']) || strpos(strtolower($Headers['connection']), 'upgrade') === FALSE)) {
            $errorResponds[] = "HTTP/1.1 400 Bad Request";
        }
        if (!isset($Headers['sec-websocket-version']) || strtolower($Headers['sec-websocket-version']) != 13) {
            $errorResponds[] = "HTTP/1.1 426 Upgrade Required\r\nSec-WebSocketVersion: 13";
        }
        if (!isset($Headers['get'])) {
            $errorResponds[] = "HTTP/1.1 405 Method Not Allowed\r\n\r\n";
        }
        if (count($errorResponds) > 0) {
            $message = implode("\r\n", $errorResponds);
            fwrite($Socket, $message, strlen($message));
            $this->onError($SocketID, "Handshake aborted - [" . trim($message) . "]");
            $this->Close($Socket);
            return false;
        }
        $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);
        $statusLine = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: $Token\r\n\r\n";
        fwrite($Socket, $statusLine, strlen($statusLine));

        $this->Clients[$SocketID]->Headers = 'websocket';
        $this->Clients[$SocketID]->Handshake = true;
        if (isset($this->allApps[$Headers['get']])) {
            $this->Clients[$SocketID]->app = $this->allApps[$Headers['get']];
        }
        return true;
    }
    

phpWebSocketServer/server/RFC6455.php


    /* 
    Let's have a closer look at this method.
    We have decode the message and have a look at the opcode encoded.
    Next we will 'release' the client by sending the 'next' message.
    If the message will come in chunks we have to collect them.
    Finally we return the message 
    */

    private function extractMessage($SocketID, $message) {
        $client = $this->Clients[$SocketID];

        $message = $this->Decode($message);
        if ($this->opcode == 10) { //pong
            $this->log("Unsolicited Pong frame received from socket #$SocketID"); // just ignore
            return '';
        }
        if ($this->opcode == 8) { //Connection Close Frame 
            $this->log("Connection Close frame received from socket #$SocketID");
            $this->Close($SocketID);
            return '';
        }
        if ($this->fin == 0 && $this->opcode == 0) {
            $this->Clients[$SocketID]->fin = false; // fragmented message
        } else if ($this->fin != 0 && $this->opcode != 0) {
            $this->Clients[$SocketID]->fin = true;
        }


        $this->Write($SocketID, json_encode((object) [
                            'opcode' => 'next',
                            'fyi' => $this->Clients[$SocketID]->fyi]));
        /*
         * ***********************************************
         * take car of buffering messages either because
         * buffrerON===true or fin===false
         * ***********************************************
         */
        if ($this->serverCommand($client, $message)) {
            return '';
        }

        if ($client->bufferON) {
            if (count($client->buffer) <= $this->maxChunks) {
                $client->buffer[] = $message;
            } else {
                $this->log("Too many chunks from socket #$SocketID");
                $this->onCLose($SocketID);
            }
            return '';
        }
        return $message;
    }
    

phpWebSocketServer/webClient/socketWebClient.php

Here are the functions that overwrite the internal dummy functions

 
function setCallbackStatus(func) {
    //  overwrite dummy call back with your own func
    callbackStatus = func;
}
function setCallbackReady(func) {
    //  overwrite dummy call back with your own func
    callbackReady = func;
}
function setCallbackReadMessage(func) {
    //  overwrite dummy call back with your own func
    callbackReadMessage = func;
}
    

phpWebSocketServer/server/webSocketServer.php

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";
    }
    $socket = 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[ intval($socket)] = $socket;
    $this->socketMaster = $socket;
    $this->allowedIP[] = gethostbyname($Address);
    $this->allowedIP[] = '::1'; // ipv6 

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

phpWebSocketServer/server/webSocketServer.php

public function registerResource($name, $app) {
    $this->allApps[$name] = $app;
    foreach (['registerServer', 'onOpen', 'onData', 'onClose', 'onError'] as $method) {
        if (!method_exists($app, $method)) {
            $this->allApps[$name] = NULL;
            return false;
        }
    }
    $app->registerServer($this);
    return true;
}
    

phpWebSocketServer/server/webSocketServer.php

private function specificChecks($SocketID) {

    $ok = true;
    $Client = $this->Clients[$SocketID];

    if ($Client->app === NULL) {
        $this->Log("Application incomplete or does not exist);"
                . " Telling Client to disconnect on  #$SocketID");
        $msg = (object) ['opcode' => 'close'];
        $this->Write($SocketID, json_encode($msg));
        $this->Close($SocketID);
        $ok = false;
    }

    if ($this->maxPerIP > 0 && $this->Clients[$SocketID]->clientType == 'websocket') {
        /*
         * ***********************************************
         * track number of websocket connectins from this IP
         * ***********************************************
         */
        $ip = $Client->ip;
        if (!isset($this->clientIPs[$ip])) {
            $this->clientIPs[$ip] = (object) [
                        'SocketId' => $SocketID,
                        'count' => 1
            ];
        } else {
            $this->clientIPs[$ip]->count++;
            if ($this->clientIPs[$ip]->count > $this->maxPerIP) {
                $msg = "To many connections from:  $ip";
                $this->Log("$SocketID, $msg");
                $this->Write($SocketID, json_encode((object) ['opcode' => 'close', 'error' => $msg]));
                $this->Close($SocketID);
                $ok = false;
            }
        }
    } else if (count($this->allowedIP) > 0 && $this->Clients[$SocketID]->clientType != 'websocket') {
        /*
         * ***********************************************
         * check if tcp client connects from allowed host
         * ***********************************************
         */
        if (!in_array($Client->ip, $this->allowedIP)) {
            $this->Close($SocketID);
            $this->Log("$SocketID, No connection allowed from: " . $Client->ip);
            $ok = false;
        }
    }
    return $ok;
}