Websockets (#7393)

* Initial commit of websockets

* Move app_menu to the active_calls websockets

* Fix hangup function

* Remove connection wait-state on web socket server so events can process

* Add timestamp and debug level to console for service debug output

* Remove debug exit

* Fix typo for ws_client instead of ws_server

* Update app_config.php

* Fix typo and remove empty function

* Remove call to empty function

* Fix the menu to point to the correct location

* Remove Logging Class

* Rename service file

* Rename service file

* Fix the in progress browser request

* Fix browser reload and implement 'active_calls' default values

* Add apply_filter function

* Create new permission_filter object

* In progress active calls now use filter

* Add invalid_uuid_exception class

* add event_key_filter to honor user permissions

* add and_link and or_link for filters

* Fix disconnected subscriber and add filters to honor permissions

* Add $key and $value for filter

* define a service name

* catch throwable instead of exception

* Add $key and $value for filter and allow returning null

* Update permission checks when loading page

* Add apply_filter function to honor subscriber permissions

* Add create_filter_chain_for function to honor subscriber permissions

* Add apply_filter function to honor subscriber permissions

* Add apply_filter function to honor subscriber permissions

* create interface to allow filterable payload

* create interface to define functions required for websocket services

* Pass in service class when creating a service token

* Allow key/name and return null for filter

* Adjust subscriber exceptions to return the ID of the subscriber

* Add event filter to filter chain

* Add command line options for ip and port for websockets and switch

* update service to use is_a syntax

* initial commit of base class for websockets system services

* initial commit of the system cpu status service

* remove extra line feed

* fix path on active_calls

* initial proof of concept for cpu status updated by websockets

* Allow returning null

* Use default settings to set the interval for cpu status broadcast

* Improve the CPU percent function for Linux systems

* Show more debug information

* Allow child processes to re-connect to the web socket service

* Fix websockets as plural instead of singular

* Add class name list-row

* Update active_calls.php

* Update active_calls.php

* Update websocket_client.js

* Update app_config.php

* Update app_menu.php

* Update debian-websockets.service

* Update debian-active_calls.service

---------

Co-authored-by: FusionPBX <markjcrane@gmail.com>
This commit is contained in:
frytimo
2025-06-24 16:07:57 -03:00
committed by GitHub
parent 86f0561e0c
commit d5286a12bc
44 changed files with 9419 additions and 22 deletions

View File

@@ -0,0 +1,218 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* A base message for communication
*
* @author Tim Fry <tim@fusionpbx.com>
* @param string $payload;
*/
class base_message {
/**
* The id is contained to the base_message class. Subclasses or child classes should not adjust this value
* @var int
*/
private $id;
/**
* Payload can be any value
* @var mixed
*/
protected $payload;
/**
* Constructs a base_message object.
* When the array is provided as an associative array, the object properties
* are filled using the array key as the property name and the value of the array
* for the value of the property in the object.
* @param array $associative_properties_array
*/
public function __construct($associative_properties_array = []) {
// Assign the unique object id given by PHP to identify the object
$this->id = spl_object_id($this);
// Assign the object properties using the associative array provided in constructor
foreach ($associative_properties_array as $property_or_method => $value) {
$this->__set($property_or_method, $value);
}
}
/**
* Returns the property from the object.
* If the method exists then the method will be called to get the value in the object property.
* If the method is not in the object then the property name is checked to see if it is valid. If the
* name is not available then an exception is thrown.
* @param string $name Name of the property
* @return mixed
* @throws InvalidProperty
*/
public function __get(string $name) {
if ($name === 'class') {
return static::class;
} elseif (method_exists($this, "get_$name")) {
// call function with 'get_' prefixed
return $this->{"get_$name"}();
} elseif (method_exists($this, $name)) {
// call function with name only
return $this->{$name}();
} elseif (property_exists($this, $name)) {
// return the property from the object
return $this->{$name};
}
}
/**
* Sets the object property in the given name to be the given value
* @param string $name Name of the object property
* @param mixed $value Value of the object property
* @return void
* @throws \InvalidArgumentException
*/
public function __set(string $name, $value): void {
if (method_exists($this, "set_$name")){
//
// By calling the method with the setter name of the property first, we give
// the child object the opportunity to modify the value before it is
// stored in the object. In the case of the key names for an event this
// allows that child class to adjust the event name from a key value of
// 'Unique-Id' to be standardized to 'unique_id'.
//
$this->{"set_$name"}($value);
} elseif (method_exists($this, $name)) {
//
// We next check for a function with the same name as the property. If the
// method exists then we call the method with the same name instead of
// setting the property directly. This allows the value to be adjusted
// before it is set in the object. Similar to the previous check.
//
$this->{$name}($value);
} elseif (property_exists($this, $name)) {
//
// Lastly, we check for the property to exist and set it directly. This
// is so the property of the child message or base message can be set.
//
$this->{$name} = $value;
}
}
/**
* Provides a method that PHP will call if the object is echoed or printed.
* @return string JSON string representing the object
* @depends to_json
*/
public function __toString(): string {
return $this->to_json();
}
/**
* Returns this object ID given by PHP
* @return int
*/
public function get_id(): int {
return $this->id;
}
/**
* Sets the message payload to be delivered
* @param mixed $payload Payload for the message to carry
* @return $this Returns this object for chaining
*/
public function set_payload($payload) {
$this->payload = $payload;
return $this;
}
/**
* Returns the payload contained in this message
* @return mixed Payload in the message
*/
public function get_payload() {
return $this->payload;
}
/**
* Alias of get_payload and set_payload. When the parameter
* is used to call the method, the payload property of the object
* is set to the payload provided and this object is returned. When
* the method is called with no parameters given, the payload is
* returned to the caller.
* Payload the message object is delivering
* @param mixed $payload If set, payload is set to the value. Otherwise, the payload is returned.
* @return mixed If payload was given to call the method, this object is returned. If no value was provided the payload is returned.
* @see set_payload
* @see get_payload
*/
public function payload($payload = null) {
if (func_num_args() > 0) {
return $this->set_payload($payload);
}
return $this->get_payload();
}
/**
* Recursively convert this object or child object to an array.
* @param mixed $iterate Private value to be set while iterating over the object properties
* @return array Array representing the properties of this object
*/
public function to_array($iterate = null): array {
$array = [];
if ($iterate === null) {
$iterate = $this;
}
foreach ($iterate as $property => $value) {
if (is_array($value)) {
$value = $this->to_array($value);
} elseif (is_object($value) && method_exists($value, 'to_array')) {
$value = $value->to_array();
} elseif (is_object($value) && method_exists($value, '__toArray')) { // PHP array casting
$value = $value->__toArray();
}
$array[$property] = $value;
}
return $array;
}
/**
* Returns a json string
* @return string
* @depends to_array
*/
public function to_json(): string {
return json_encode($this->to_array());
}
/**
* Returns an array representing this object or child object.
* @return array Array of object properties
*/
public function __toArray(): array {
return $this->to_array();
}
}

View File

@@ -0,0 +1,271 @@
<?php
/**
* Description of system_dashboard_service
*
* @author Tim Fry <tim@fusionpbx.com>
*/
abstract class base_websocket_system_service extends service implements websocket_service_interface {
private static $websocket_port = null;
private static $websocket_host = null;
/**
* Sets a time to fire the on_timer function
* @var int|null
*/
protected $timer_expire_time = null;
/**
* Websocket client
* @var websocket_client $ws_client
*/
protected $ws_client;
abstract protected function reload_settings(): void;
protected static function display_version(): void {
echo "System Dashboard Service 1.0\n";
}
/**
* Set a timer to trigger the on_timer function every $seconds. To stop the timer, set the value to null
* @param int $seconds
* @return void
* @see on_timer
*/
protected function set_timer(?int $seconds): void {
if ($seconds !== null) $this->timer_expire_time = time() + $seconds;
else $this->timer_expire_time = null;
}
/**
* When the set_timer is used to set a timer, this function will run. Override
* the function in the child class.
* @return void
* @see set_timer
*/
protected function on_timer(): void {
return;
}
protected static function set_command_options() {
parent::append_command_option(
command_option::new()
->description('Set the Port to connect to the websockets service')
->short_option('w')
->short_description('-w <port>')
->long_option('websockets-port')
->long_description('--websockets-port <port>')
->callback('set_websockets_port')
);
parent::append_command_option(
command_option::new()
->description('Set the IP address for the websocket')
->short_option('k')
->short_description('-k <ip_addr>')
->long_option('websockets-address')
->long_description('--websockets-address <ip_addr>')
->callback('set_websockets_host_address')
);
}
protected static function set_websockets_port($port): void {
self::$websocket_port = $port;
}
protected static function set_websockets_host_address($host): void {
self::$websocket_host = $host;
}
public function run(): int {
// re-read the config file to get any possible changes
parent::$config->read();
// re-connect to the websocket server
$this->connect_to_ws_server();
// Notify connected web server socket when we close
register_shutdown_function(function ($ws_client) {
if ($ws_client !== null)
$ws_client->disconnect();
}, $this->ws_client);
$this->register_topics();
// Register the authenticate request
$this->on_topic('authenticate', [$this, 'on_authenticate']);
// Track the WebSocket Server Error Message so it doesn't flood the system logs
$suppress_ws_message = false;
while ($this->running) {
$read = [];
// reconnect to websocket server
if ($this->ws_client === null || !$this->ws_client->is_connected()) {
// reconnect failed
if (!$this->connect_to_ws_server()) {
if (!$suppress_ws_message) $this->error("Unable to connect to websocket server.");
$suppress_ws_message = true;
}
}
if ($this->ws_client !== null && $this->ws_client->is_connected()) {
$read[] = $this->ws_client->socket();
$suppress_ws_message = false;
}
// Check if we have sockets to read
if (!empty($read)) {
$write = $except = [];
// Wait for an event and timeout at 1/3 of a second so we can re-check all connections
if (false === stream_select($read, $write, $except, 0, 333333)) {
// severe error encountered so exit
$this->running = false;
// Exit with non-zero exit code
return 1;
}
// stream_select will update $read so re-check it
if (!empty($read)) {
$this->debug("Received event");
// Iterate over each socket event
foreach ($read as $resource) {
// Web socket event
if ($resource === $this->ws_client->socket()) {
$this->handle_websocket_event($this->ws_client);
continue;
}
}
}
}
// Timers can be set by child classes
if ($this->timer_expire_time !== null && time() >= $this->timer_expire_time) {
$this->on_timer();
// Set another timer to fire again
$this->set_timer(3);
}
}
return 0;
}
protected function debug(string $message) {
self::log($message, LOG_DEBUG);
}
protected function warn(string $message) {
self::log($message, LOG_WARNING);
}
protected function error(string $message) {
self::log($message, LOG_ERR);
}
protected function info(string $message) {
self::log($message, LOG_INFO);
}
/**
* Connects to the web socket server using a websocket_client object
* @return bool
*/
protected function connect_to_ws_server(): bool {
if ($this->ws_client !== null && $this->ws_client->is_connected()) return true;
$host = self::$websocket_host ?? self::$config->get('websocket.host', '127.0.0.1');
$port = self::$websocket_port ?? self::$config->get('websocket.port', 8080);
try {
// Create a websocket client
$this->ws_client = new websocket_client("ws://$host:$port");
// Block stream for handshake and authentication
$this->ws_client->set_blocking(true);
// Connect to web socket server
$this->ws_client->connect();
// Disable the stream blocking
$this->ws_client->set_blocking(false);
$this->debug(self::class . " RESOURCE ID: " . $this->ws_client->socket());
} catch (\RuntimeException $re) {
//unable to connect
return false;
}
return true;
}
/**
* Handles the message from the web socket client and triggers the appropriate requested topic event
* @param resource $ws_client
* @return void
*/
private function handle_websocket_event() {
// Read the JSON string
$json_string = $this->ws_client->read();
// Nothing to do
if ($json_string === null) {
$this->warn('Message received from Websocket is empty');
return;
}
$this->debug("Received message on websocket: $json_string (" . strlen($json_string) . " bytes)");
// Get the web socket message as an object
$message = websocket_message::create_from_json_message($json_string);
// Nothing to do
if (empty($message->topic())) {
$this->error("Message received does not have topic");
return;
}
// Call the registered topic event
$this->trigger_topic($message->topic, $message, $this->ws_client);
}
/**
* Call each of the registered events for the websocket topic that has arrived
* @param string $topic
* @param websocket_message $websocket_message
*/
private function trigger_topic(string $topic, websocket_message $websocket_message) {
if (empty($topic) || empty($websocket_message)) {
return;
}
if (!empty($this->topics[$topic])) {
foreach ($this->topics[$topic] as $callback) {
call_user_func($callback, $websocket_message);
}
}
}
protected function on_authenticate(websocket_message $websocket_message) {
$this->info("Authenticating with websocket server");
// Create a service token
[$token_name, $token_hash] = websocket_client::create_service_token(active_calls_service::get_service_name(), static::class);
// Request authentication as a service
$this->ws_client->authenticate($token_name, $token_hash);
}
/**
* Allows the service to register a callback so when the topic arrives the callable is called
* @param type $topic
* @param type $callable
*/
protected function on_topic($topic, $callable) {
if (!isset($this->topics[$topic])) {
$this->topics[$topic] = [];
}
$this->topics[$topic][] = $callable;
}
protected function respond(websocket_message $websocket_message): void {
websocket_client::send($this->ws_client->socket(), $websocket_message);
}
abstract protected function register_topics(): void;
}

View File

@@ -0,0 +1,38 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* A file not found exception
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class file_not_found_exception extends \Exception {
public function __construct(string $message = "File not found", int $code = 0, ?\Throwable $previous = null) {
return parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,79 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* Description of permission_filter
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class permission_filter implements filter {
private $field_map;
private $permissions;
public function __construct(array $event_field_key_to_permission_map, array $permissions = []) {
$this->field_map = $event_field_key_to_permission_map;
$this->add_permissions($permissions);
}
public function __invoke(string $key, $value): ?bool {
$permission = $this->field_map[$key] ?? null;
if ($permission === null || $this->has_permission($permission)) {
return true;
}
return false;
}
/**
* Adds an associative array of permissions where $key is the name of the permission and $value is ignored as it should always be set to true.
* @param array $permissions
*/
public function add_permissions(array $permissions) {
// Add all event key filters passed
foreach (array_keys($permissions) as $key) {
$this->add_permission($key);
}
}
/**
* Adds a single permission
* @param string $key
*/
public function add_permission(string $key) {
$this->permissions[$key] = $key;
}
/**
* Checks if the filter has a permission
* @param string $key
* @return bool
*/
public function has_permission(string $key): bool {
return isset($this->permissions[$key]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* A socket disconnects exception
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class socket_disconnected_exception extends \socket_exception {
public function __construct($resource_id, string $message = "Socket Disconnected", int $code = 0, ?\Throwable $previous = null) {
return parent::__construct($resource_id, $message, $code, $previous);
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* General socket exception class
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class socket_exception extends \Exception {
public $id;
public function __construct($id = null, string $message = "", int $code = 0, ?\Throwable $previous = null) {
$this->id = $id;
return parent::__construct($message, $code, $previous);
}
public function getResourceId() { return $this->resource_id; }
}

View File

@@ -0,0 +1,650 @@
<?php
declare(strict_types=1);
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* Description of subscriber
* @author Tim Fry <tim@fusionpbx.com>
*/
class subscriber {
public $show_all;
/**
* The ID of the object given by PHP
* @var spl_object_id
*/
private $id;
private $socket;
/**
* Stores the original socket ID used when the subscriber object was created.
* The resource is cast to an integer and then saved in order to match the
* a resource to the original socket. This is primarily used in the equals
* method to test for equality.
* @var int
*/
private $socket_id;
private $remote_ip;
private $remote_port;
private $services;
private $permissions;
private $domain_name;
private $domain_uuid;
private $token_hash;
private $token_name;
private $token_time;
private $token_limit;
private $enable_token_time_limit;
private $service;
private $service_class;
private $service_name;
private $filter;
/**
* Function or method name to call when sending information through the socket
* @var callable
*/
private $callback;
private $send_all;
private $subscriptions;
private $authenticated;
/**
* Creates a subscriber object.
* @param resource|stream $socket Connected socket
* @param callable $frame_wrapper The callback used to wrap communication in a web socket frame. Sending NULL to the frame wrapper should send a disconnect.
* @throws \socket_exception Thrown when the passed socket is already closed
* @throws \InvalidArgumentException Thrown when the $callback is not a valid callback
*/
public function __construct($socket, callable $frame_wrapper) {
if (!is_resource($socket)) {
throw new \socket_exception('Socket must be a valid resource');
}
// check for valid callback so we can send websocket data when required
if (!is_callable($frame_wrapper)) {
throw new \InvalidArgumentException('Websocket callable method must be a valid callable function or method');
}
// set object identifiers
$this->id = md5(spl_object_hash($this)); // PHP unique object hash is similar to 000000000000000f0000000000000000 so we use md5
$this->socket = $socket;
$this->socket_id = (int) $socket;
$this->domain_name = '';
$this->domain_uuid = '';
// always use the same formula from the static functions
[$this->remote_ip, $this->remote_port] = self::get_remote_information_from_socket($socket);
// set defaults
$this->authenticated = false;
$this->permissions = [];
$this->services = [];
$this->show_all = false;
$this->enable_token_time_limit = false;
$this->subscriptions = [];
$this->service = false;
$this->service_name = '';
// Save the websocket frame wrapper used to communicate to this subscriber
$this->callback = $frame_wrapper;
// No filter initially
$this->filter = null;
}
/**
* Gets or sets the subscribed to services
* @param array $services
* @return $this|array
*/
public function subscribed_to($services = []) {
if (func_num_args() > 0) {
$this->services = array_flip($services);
return $this;
}
return array_keys($this->services);
}
public function service_class($service_class = null) {
if (func_num_args() > 0) {
$this->service_class = $service_class;
return $this;
}
return $this->service_class;
}
public function set_filter(filter $filter) {
$this->filter = $filter;
return $this;
}
public function get_filter() {
return $this->filter;
}
/**
* When there is no more references to the object we ensure that we disconnect from the subscriber
*/
public function __destruct() {
// disconnect the socket
$this->disconnect();
}
/**
* Disconnects the socket resource used for this subscriber
* @return bool true on success and false on failure
*/
public function disconnect(): bool {
//return success if close was successful
if (is_resource($this->socket)) {
//self::$logger->info("Subscriber $this->id has been disconnected");
// Send null to the frame wrapper to send a disconnect frame
call_user_func($this->callback, $this->socket_id, null);
return (@fclose($this->socket) !== false);
}
return false;
}
/**
* Compares the current object with another object to see if they are exactly the same object
* @param subscriber|resource $object_or_resource_or_id
* @return bool
*/
public function equals($object_or_resource_or_id): bool {
// Compare by resource
if (is_resource($object_or_resource_or_id)) {
return $object_or_resource_or_id === $this->socket;
}
// Compare by spl_object_id or spl_object_hash
if (gettype($object_or_resource_or_id) === 'integer' || gettype($object_or_resource_or_id) === 'string') {
return $object_or_resource_or_id === $this->id;
}
// Ensure it is the same type of object
if (!($object_or_resource_or_id instanceof subscriber)) {
// Not a subscriber object
return false;
}
// Compare by object using the spl_object_id to match
return $object_or_resource_or_id->id() === $this->id;
}
public function not_equals($object_or_resource): bool {
return !$this->equals($object_or_resource);
}
/**
* Allow accessing copies of the private values
* @param string $name
* @return mixed
* @throws \InvalidArgumentException
*/
public function __get(string $name) {
switch ($name) {
case 'id':
case 'socket_id':
case 'remote_ip':
case 'remote_port':
case 'token_name':
case 'token_hash':
case 'token_time':
case 'domain_name':
case 'permissions':
case 'services':
return $this->{$name};
default:
throw new \InvalidArgumentException("Property '$name' does not exist or direct access is prohibited. Try using '$name()' for access.");
}
}
/**
* Returns the current ID of this subscriber.
* The ID is set in the constructor using the spl_object_id given by PHP
* @return string
*/
public function id(): string {
return "$this->id";
}
/**
* Checks if this subscriber has the permission given in $permission
* @param string $permission
* @return bool True when this subscriber has the permission and false otherwise
*/
public function has_permission(string $permission): bool {
// Do not allow empty names
if (empty($this->permissions) || strlen($permission) === 0) {
return false;
}
return isset($this->permissions[$permission]);
}
public function get_permissions(): array {
return $this->permissions;
}
public function get_domain_name(): string {
return $this->domain_name;
}
/**
* Returns the current socket resource used to communicate with this subscriber
* @return resource|stream Resource Id or stream used
*/
public function socket() {
return $this->socket;
}
/**
* Returns the socket ID that was cast to an integer when the object was
* created
*/
public function socket_id(): int {
return $this->socket_id;
}
/**
* Validates the given token against the loaded token in the this subscriber
* @param array $token Must be an associative array with name and hash as the keys.
* @return bool
*/
public function is_valid_token(array $token): bool {
if (!is_array($token)) {
throw new \InvalidArgumentException('Token must be an array');
}
// get the name and hash from array
$token_name = $token['name'] ?? '';
$token_hash = $token['hash'] ?? '';
// empty values are not allowed
if (empty($token_name) || empty($token_hash)) {
return false;
}
// validate the name and hash
$valid = ($token_name === $this->token_name && $token_hash === $this->token_hash);
// Get the current epoch time
$server_time = time();
// check time validation required
if ($this->enable_token_time_limit) {
// compare against time limit in minutes
$valid = $valid && ($server_time - $this->token_time < $this->token_limit * 60);
}
//self::$logger->debug("------------------ Token Compare ------------------");
//self::$logger->debug("Subscriber token time: $this->token_time");
//self::$logger->debug(" Server time: $server_time");
//self::$logger->debug("Subscriber token name: $this->token_name");
//self::$logger->debug(" Server token name: $token_name");
//self::$logger->debug("Subscriber token hash: $this->token_hash");
//self::$logger->debug(" Server token hash: $token_hash");
//self::$logger->debug("Returning: " . ($valid ? 'true' : 'false'));
//self::$logger->debug("---------------------------------------------------");
return $valid;
}
/**
* Validates the given token array against the token previously saved in the file system. When the token is valid
* the token will be saved in this object and the file removed. This method should not be called a second time
* once a token has be authenticated.
* @param array $request_token
* @return bool
*/
public function authenticate_token(array $request_token): bool {
// Check connection
if (!$this->is_connected()) {
throw new \socket_disconnected_exception($this->id);
}
// Check for required fields
if (empty($request_token)) {
$date = date('Y/m/d H:i:s', time());
//self::$logger->warn("Empty token given for $this->id");
return false;
}
// Set local storage
$token_file = self::get_token_file($request_token['name'] ?? '');
// Set default return value of false
$valid = false;
//self::$logger->debug("Using file: $token_file");
// Ensure the file is there
if (file_exists($token_file)) {
//self::$logger->debug("Using $token_file for token");
// Get the token using PHP engine parsing (fastest method)
$array = include($token_file);
// Assign to local variables to reflect local storage
$token_name = $array['token']['name'] ?? '';
$token_hash = $array['token']['hash'] ?? '';
$token_time = intval($array['token']['time'] ?? 0);
$token_limit = intval($array['token']['limit'] ?? 0);
// Compare the token given in the request with the one that was in local storage
$valid = $token_name === $request_token['name'] && $token_hash === $request_token['hash'];
// If the token is supposed to have a time limit then check the token time
if ($token_limit > 0) {
// check time has expired or not and put it in valid
$valid = $valid && (time() - $token_time < $token_limit * 60); // token_time_limit * 60 seconds = 15 minutes
}
// Debug information
if (true) {
//self::$logger->debug("------------------ Authenticate Token Compare ------------------");
//self::$logger->debug(" Subscriber token name: ".$request_token['name']);
//self::$logger->debug(" Subscriber token hash: ".$request_token['hash']);
//self::$logger->debug(" Server token name: $token_name");
//self::$logger->debug(" Server token hash: $token_hash");
//self::$logger->debug(" Server token time: $token_time");
//self::$logger->debug(" Server token limit: $token_limit");
//self::$logger->debug("Valid: " . ($valid ? 'yes' : 'no'));
//self::$logger->debug("----------------------------------------------------------------");
}
// When token is valid
if ($valid) {
// Store the valid token information in this object
$this->token_name = $token_name;
$this->token_hash = $token_hash;
$this->token_time = $token_time;
$this->enable_token_time_limit = $token_limit > 0;
$this->token_limit = $token_limit * 60; // convert to seconds for time() comparison
// Add the domain
$this->domain_name = $array['domain']['name'] ?? '';
$this->domain_uuid = $array['domain']['uuid'] ?? '';
// Add subscriptions for services
$services = $array['services'] ?? [];
foreach ($services as $service) {
$this->subscribe($service);
}
// Store the permissions
$this->permissions = $array['permissions'] ?? [];
// Check for service
if (isset($array['service'])) {
//
// Set the service information in the object
//
$this->service_name = "" . ($array['service_name'] ?? '');
$this->service_class = "" . ($array['service_class'] ?? '');
//
// Ensure we can call the method we need by checking for the interface.
// Using the interface instead of calling method_exists means we only have to check once
// for the interface instead of checking for each individual method required for it to be
// considered a service. We can also adjust the interface with new methods and this code
// remains the same. It is also possbile for us to use the 'instanceof' operator to check
// that the object is what we require. However, using the instanceof operator requires anc
// object first. Here we only check that the class has implemented the interface allowing
// us to call static methods without first creating an object.
//
$this->service = is_a($this->service_class, 'websocket_service_interface', true);
}
//self::$logger->debug("Permission count(".count($this->permissions) . ")");
}
// Remove the token from local storage
@unlink($token_file);
}
// store the result
$this->authenticated = $valid;
// return success or failed
return $valid;
}
public function is_authenticated(): bool {
return $this->authenticated;
}
public function set_authenticated(bool $authenticated): self {
return $this;
}
public function set_domain(string $uuid, string $name): self {
if (is_uuid($uuid)) {
$this->uuid = $uuid;
} else {
throw new invalid_uuid_exception("UUID is not valid");
}
$this->domain_name = $name;
return $this;
}
public function is_service(): bool {
return $this->service;
}
/**
* Get or set the service_name
* @param string|null $service_name
* @return string|$this
*/
public function service_name($service_name = null) { /* : string|self */
if (func_num_args() > 0) {
$this->service_name = $service_name;
return $this;
}
return $this->service_name;
}
public function service_equals(string $service_name): bool {
return ($this->service && $this->service_name === $service_name);
}
/**
* Returns true if the socket/stream is still open (not at EOF).
* @return bool Returns true if connected and false if the connection has closed
*/
public function is_connected(): bool {
return is_resource($this->socket) && !feof($this->socket);
}
/**
* Returns true if the subscriber is no longer connected
* @return bool Returns true if the subscriber is no longer connected
*/
public function is_not_connected(): bool {
return !$this->is_connected();
}
/**
* Checks if this subscriber is subscribed to the given service name
* @param string $service_name The service name ie. active.calls
* @return bool
* @see subscriber::subscribe
*/
public function has_subscribed_to(string $service_name): bool {
return isset($this->services[$service_name]);
}
public function subscribe(string $service_name): self {
$this->services[$service_name] = true;
return $this;
}
/**
* Sends a response to the subscriber using the provided callback web socket wrapper in the constructor
* @param string $json Valid JSON response to send to the connected client
* @throws subscriber_token_expired_exception Thrown when the time limit set in the token has expired
*/
public function send(string $json) {
//ensure the token is still valid
if (!$this->token_time_exceeded()) {
call_user_func($this->callback, $this->socket, $json);
} else {
throw new subscriber_token_expired_exception($this->id);
}
}
/**
* Sends the given message through the websocket
* @param websocket_message $message
* @throws socket_disconnected_exception
*/
public function send_message(websocket_message $message) {
// Filter the message
if ($this->filter !== null) {
$message->apply_filter($this->filter);
}
if (empty($message->service_name())) {
return;
}
// Check that we are subscribed to the event
if (!$this->has_subscribed_to($message->service_name())) {
//self::$logger->warn("Subscriber not subscribed to " . $message->service_name());
throw new subscriber_not_subscribed_exception($this->id);
}
// Ensure we are still connected
if (!$this->is_connected()) {
throw new \socket_disconnected_exception($this->id);
}
$this->send((string) $message);
return;
}
public static function get_remote_information_from_socket($socket): array {
return explode(':', stream_socket_get_name($socket, true), 2);
}
public static function get_remote_ip_from_socket($socket): string {
$array = explode(':', stream_socket_get_name($socket, true), 2);
return $array[0] ?? '';
}
public static function get_remote_port_from_socket($socket): string {
$array = explode(':', stream_socket_get_name($socket, true), 2);
return $array[1] ?? '';
}
public static function get_token_file($token_name): string {
// Try to store in RAM first
if (is_dir('/dev/shm') && is_writable('/dev/shm')) {
$token_file = '/dev/shm/' . $token_name . '.php';
} else {
// Use the filesystem
$token_file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $token_name . '.php';
}
return $token_file;
}
/**
* Saves the token array to local file system
*
* The web socket server runs in a separate process so it is unable to use
* sessions. Therefor, the token must be stored in a temp folder to be
* verified by the web socket server. It is possible to use a database
* but the database connection process is very slow compared to the file
* system. If the database resides on a remote system instead of local,
* the web socket service may not yet have access to the token before the
* web socket client requests authorization.
*
* @param array $token Standard token issued from the token object
* @param array $services A simple array list of service names to subscribe to
* @param int $time_limit_in_minutes Set a token time limit. Setting to zero will disable the time limit
* @see token::create()
*/
public static function save_token(array $token, array $services, int $time_limit_in_minutes = 0) {
//
// Put the domain_name, permissions, and token in local storage so we can use all the information
// to authenticate an incoming connection from the websocket service.
//
$array['permissions'] = $_SESSION['permissions'];
//
// Store the token service and events
//
$array['services'] = $services;
//
// Store the name and hash of the token
//
$array['token']['name'] = $token['name'];
$array['token']['hash'] = $token['hash'];
//
// Store the epoch time and time limit
//
$array['token']['time'] = "" . time();
$array['token']['limit'] = $time_limit_in_minutes;
//
// Store the domain name in this session
//
$array['domain']['name'] = $_SESSION['domain_name'];
$array['domain']['uuid'] = $_SESSION['domain_uuid'];
//
// Get the full path and file name for storing the token
//
$token_file = self::get_token_file($token['name']);
$file_contents = "<?php\nreturn " . var_export($array, true) . ";\n";
//
// Put the contents in the file using the PHP method var_export. This is the fastest method to import
// later because we can use the speed of the Zend Engine to import it with a simple include statement
// The include can be used as a function: "$array = include($token_file);"
//
file_put_contents($token_file, $file_contents);
}
public function token_time_exceeded(): bool {
if (!$this->enable_token_time_limit)
return false;
//self::$logger->debug("------------- TOKEN TIME LIMIT -------------");
//self::$logger->debug(" Token Limit: $this->token_limit");
//self::$logger->debug(" Token Time: $this->token_time");
//self::$logger->debug(" Current Time: " . time());
//self::$logger->debug("time-token_time: " . (time() - $this->token_time));
//self::$logger->debug(" Time Exceeded: " . ((time() - $this->token_time) > $this->token_limit ? 'Yes' : 'No'));
//self::$logger->debug("--------------------------------------------");
//test the time on the token to ensure it is valid
return (time() - $this->token_time) > $this->token_limit;
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* Description of SubscriberException
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class subscriber_exception extends \Exception {
public $subscriber_id;
public function __construct($subscriber_id, string $message = "", int $code = 0, ?\Throwable $previous = null) {
parent::__construct($message, $code, $previous);
$this->subscriber_id = $subscriber_id;
}
public function getSubscriberId() { return $this->subscriber_id; }
}

View File

@@ -0,0 +1,38 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* Description of SubscriberMissingPermissionException
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class subscriber_missing_permission_exception extends \subscriber_exception {
public function __construct($subscriber_id, string $message = "Subscriber is missing required permission", int $code = 0, ?\Throwable $previous = null): \Exception {
return parent::__construct($subscriber_id, $message, $code, $previous);
}
}

View File

@@ -0,0 +1,38 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* Description of subscriber_not_subscribed_exception
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class subscriber_not_subscribed_exception extends subscriber_exception {
public function __construct($subscriber_id, string $message = "Subscriber is not subscribed", int $code = 0, ?\Throwable $previous = null) {
parent::__construct($subscriber_id, $message, $code, $previous);
}
}

View File

@@ -0,0 +1,38 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* Description of TokenExpired
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class subscriber_token_expired_exception extends \subscriber_exception {
public function __construct($subscriber_id = null, string $message = "Subscriber token expired", int $code = 0, ?\Throwable $previous = null) {
return parent::__construct($subscriber_id, $message, $code, $previous);
}
}

View File

@@ -0,0 +1,424 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* Simple WebSocket client class in pure PHP (PHP 8.1+).
* Provides connect, send_message, and disconnect methods.
*/
class websocket_client {
protected $url;
protected $resource;
protected $host;
protected $port;
protected $path;
protected $origin;
protected $key;
private $stream_blocking;
/**
* @param string $url WebSocket URL (e.g. ws://127.0.0.1:8080/)
*/
public function __construct(string $url) {
$this->url = $url;
//blocking should be enabled until we perform a handshake
$this->stream_blocking = true;
}
public function socket() {
return $this->resource;
}
/**
* Connects to the WebSocket server and performs handshake.
*/
public function connect(): void {
$parts = parse_url($this->url);
$this->host = $parts['host'] ?? '';
$this->port = $parts['port'] ?? 80;
$this->path = $parts['path'] ?? '/';
$this->origin = ($parts['scheme'] ?? 'http') . '://' . $this->host;
$this->resource = stream_socket_client("tcp://{$this->host}:{$this->port}", $errno, $errstr, 5);
if (!$this->resource) {
throw new \RuntimeException("Unable to connect: ({$errno}) {$errstr}");
}
// block the stream
$is_blocking = $this->is_blocking();
if (!$is_blocking) {
$this->block();
}
// generate WebSocket key
$this->key = base64_encode(random_bytes(16));
// send handshake request
$header = "GET {$this->path} HTTP/1.1\r\n";
$header .= "Host: {$this->host}:{$this->port}\r\n";
$header .= "Upgrade: websocket\r\n";
$header .= "Connection: Upgrade\r\n";
$header .= "Sec-WebSocket-Key: {$this->key}\r\n";
$header .= "Sec-WebSocket-Version: 13\r\n";
$header .= "Origin: {$this->origin}\r\n\r\n";
fwrite($this->resource, $header);
// read response headers
$response = '';
while (!feof($this->resource)) {
$line = fgets($this->resource);
if ($line === "\r\n") {
break;
}
$response .= $line;
}
if (!preg_match('/Sec-WebSocket-Accept: (.*)\r\n/', $response, $m)) {
throw new \RuntimeException("Handshake failed: no Accept header");
}
$accept = trim($m[1]);
$expected = base64_encode(sha1($this->key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
if ($accept !== $expected) {
throw new \RuntimeException("Handshake failed: invalid Accept key");
}
// Put the blocking back to the previous state
if (!$is_blocking) {
$this->disable_block();
}
}
public function set_blocking(bool $block) {
if ($this->is_connected())
stream_set_blocking($this->resource, $block);
}
public function block() {
$this->set_blocking(true);
}
public function unblock() {
$this->set_blocking(false);
}
public function is_blocking(): bool {
if ($this->is_connected()) {
//
// We allow the socket() function to return the socket as a reference
// so we have to check the actual socket data to see if blocking was
// modified outside of the object.
// $meta_data['blocked'] = 0 // not blocking for event
// $meta_data['blocked'] = 1 // blocking for event
//
$meta_data = stream_get_meta_data($this->resource);
return !empty($meta_data['blocked']);
}
return false;
}
/**
* Returns true if socket is connected.
*/
public function is_connected(): bool {
return isset($this->resource) && is_resource($this->resource) && !feof($this->resource);
}
/**
* Sends text to the web socket server.
* The web socket client wraps the payload in a web frame socket before sending on the socket.
* @param string|null $payload
*/
public static function send($resource, ?string $payload): bool {
if (!is_resource($resource)) {
throw new \RuntimeException("Not connected");
}
// Check for a null message and send a disconnect frame
if ($payload === null) {
@fwrite($resource, chr(0x88) . chr(0x00));
return true;
}
$frame_header = "\x81"; // FIN=1, opcode=1 (text frame)
$length = strlen($payload);
// Set mask bit and payload length
if ($length <= 125) {
$frame_header .= chr(0x80 | $length); // mask bit set
} elseif ($length <= 65535) {
$frame_header .= chr(0x80 | 126) . pack('n', $length);
} else {
$frame_header .= chr(0x80 | 127) . pack('J', $length);
}
// must be masked when sending to the server
$mask = random_bytes(4);
$masked_payload = '';
for ($i = 0; $i < $length; ++$i) {
$masked_payload .= $payload[$i] ^ $mask[$i % 4];
}
$frame = $frame_header . $mask . $masked_payload;
$written = @fwrite($resource, $frame);
if ($written === false) {
echo "[ERROR] Failed to write to socket\n";
return false;
}
if ($written < strlen($frame)) {
echo "[WARNING] Partial frame sent ({$written}/" . strlen($frame) . " bytes)\n";
return false;
}
return true;
}
/**
* Disconnects from the server.
*/
public function disconnect(): void {
if (isset($this->resource) && is_resource($this->resource)) {
@fwrite($this->resource, "\x88\x00"); // 0x88 = close frame, no payload
@fclose($this->resource);
}
}
public static function get_token_file($token_name): string {
// Try to store in RAM first
if (is_dir('/dev/shm') && is_writable('/dev/shm')) {
$token_file = '/dev/shm/' . $token_name . '.php';
} else {
// Use the filesystem
$token_file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $token_name . '.php';
}
return $token_file;
}
private function send_control_frame(int $opcode, string $payload = ''): void {
$header = chr(0x80 | $opcode); // FIN=1, control frame
$payload_len = strlen($payload);
// Payload length
if ($payload_len <= 125) {
$header .= chr($payload_len);
} elseif ($payload_len <= 65535) {
$header .= chr(126) . pack('n', $payload_len);
} else {
// Control frames should never be this large; truncate to 125
$payload = substr($payload, 0, 125);
$header .= chr(125);
}
@fwrite($this->resource, $header . $payload);
}
/**
* Reads a web socket data frame and converts it to a regular string
* @param resource $this->resource
* @return string
*/
public function read(): ?string {
if (!is_resource($this->resource)) {
throw new \RuntimeException("Not connected");
}
$final_frame = false;
$payload_data = '';
while (!$final_frame) {
$header = $this->read_bytes(2);
if ($header === null)
return null;
$byte1 = ord($header[0]);
$byte2 = ord($header[1]);
$final_frame = ($byte1 >> 7) & 1;
$opcode = $byte1 & 0x0F;
$masked = ($byte2 >> 7) & 1;
$payload_len = $byte2 & 0x7F;
// Extended payload length
if ($payload_len === 126) {
$extended = $this->read_bytes(2);
if ($extended === null)
return null;
$payload_len = unpack('n', $extended)[1];
} elseif ($payload_len === 127) {
$extended = $this->read_bytes(8);
if ($extended === null)
return null;
$payload_len = 0;
for ($i = 0; $i < 8; $i++) {
$payload_len = ($payload_len << 8) | ord($extended[$i]);
}
}
// Read mask
$mask = '';
if ($masked) {
$mask = $this->read_bytes(4);
if ($mask === null)
return null;
}
// Read payload
$payload = $this->read_bytes($payload_len);
if ($payload === null) {
echo "[ERROR] Incomplete payload received\n";
return null;
}
// Unmask if needed
if ($masked) {
$unmasked = '';
for ($i = 0; $i < $payload_len; $i++) {
$unmasked .= $payload[$i] ^ $mask[$i % 4];
}
$payload = $unmasked;
}
// Handle control frames
switch ($opcode) {
case 0x9: // PING
// Respond with PONG using same payload
$this->send_control_frame(0xA, $payload);
echo "[INFO] Received PING, sent PONG\n";
continue; // Skip returning PING
case 0xA: // PONG
echo "[INFO] Received PONG\n";
continue; // Skip returning PONG
case 0x1: // TEXT frame
case 0x0: // Continuation frame
$payload_data .= $payload;
break;
default:
echo "[WARNING] Unsupported opcode: $opcode\n";
return null;
}
}
$meta = stream_get_meta_data($this->resource);
if ($meta['unread_bytes'] > 0) {
echo "[WARNING] {$meta['unread_bytes']} bytes left in socket after read\n";
}
return $payload_data;
}
// Helper function to fully read N bytes
private function read_bytes(int $length): ?string {
$data = '';
while (strlen($data) < $length) {
$chunk = fread($this->resource, $length - strlen($data));
if ($chunk === false || $chunk === '') {
break;
}
$data .= $chunk;
}
return strlen($data) === $length ? $data : null;
}
public function authenticate($token_name, $token_hash) {
return self::send($this->resource, json_encode(['service' => 'authentication', 'token' => ['name' => $token_name, 'hash' => $token_hash]]));
}
/**
* Create a token for a service that can broadcast a message
* @param string $service_name
* @param string $service_class
* @param array $permissions
* @param int $time_limit_in_minutes
* @return array
*/
public static function create_service_token(string $service_name, string $service_class, array $permissions = [], int $time_limit_in_minutes = 0) {
//
// Create a service token
//
$token = (new token())->create($service_name);
//
// Put the permissions, and token in local storage so we can use all the information
// to authenticate an incoming connection from the websocket service.
//
$array = $permissions;
//
// Store the name and hash of the token
//
$array['token']['name'] = $token['name'];
$array['token']['hash'] = $token['hash'];
//
// Store the epoch time and time limit
//
$array['token']['time'] = "" . time();
$array['token']['limit'] = $time_limit_in_minutes;
//
// Store the service name used by web browser to subscribe
// and store the class name of this service
//
$array['service'] = true;
$array['service_name'] = $service_name;
$array['service_class'] = $service_class;
//
// Get the full path and file name for storing the token
//
$token_file = self::get_token_file($token['name']);
$file_contents = "<?php\nreturn " . var_export($array, true) . ";\n";
//
// Put the contents in the file using the PHP method var_export. This is the fastest method to import
// later because we can use the speed of the Zend Engine to import it with a simple include statement
// The include can be used as a function: "$array = include($token_file);"
//
file_put_contents($token_file, $file_contents);
return [$array['token']['name'], $array['token']['hash']];
}
}
/**
* Example usage:
*/
// require_once 'websocket_client.php';
//$client = new websocket_client('ws://127.0.0.1:8080/');
//try {
// $client->connect();
// $client->send_message('Hello from PHP client!');
// // ... do more send_message() calls as needed
// $client->disconnect();
//} catch (\Throwable $e) {
// echo "Error: " . $e->getMessage() . "\n";
//}

View File

@@ -0,0 +1,429 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* A structured web socket message easily converted to and from a json string
*
* @author Tim Fry <tim@fusionpbx.com>
* @param string $service_name;
* @param string $token_name;
* @param string $token_hash;
* @param string $status_string;
* @param string $status_code;
* @param string $request_id;
* @param string $resource_id;
* @param string $domain_uuid;
* @param string $permissions;
* @param string $topic;
*/
class websocket_message extends base_message {
// By setting these to protected we ensure the __set and __get methods are used in the parent class
protected $service_name;
protected $token_name;
protected $token_hash;
protected $status_string;
protected $status_code;
protected $request_id;
protected $resource_id;
protected $domain_uuid;
protected $domain_name;
protected $permissions;
protected $topic;
public function __construct($associative_properties_array = []) {
// Initialize empty default values
$this->service_name = '';
$this->token_name = '';
$this->token_hash = '';
$this->status_string = '';
$this->status_code = '';
$this->request_id = '';
$this->resource_id = '';
$this->domain_uuid = '';
$this->domain_name = '';
$this->permissions = [];
$this->topic = '';
//
// Send to parent (base_message) constructor
//
parent::__construct($associative_properties_array);
}
public function has_permission($permission_name) {
return isset($this->permissions[$permission_name]);
}
/**
* Alias of service_name.
* @param string $service_name
* @return $this
* @see service_name
*/
public function service($service_name = null) {
if (func_num_args() > 0) {
$this->service_name = $service_name;
return $this;
}
return $this->service_name;
}
/**
* Gets or Sets the service name
* If no parameters are provided then the service_name is returned. If the service name is provided, then the
* service_name is set to the value provided.
* @param string $service_name
* @return $this
*/
public function service_name($service_name = null) {
if (func_num_args() > 0) {
$this->service_name = $service_name;
return $this;
}
return $this->service_name;
}
/**
* Gets or sets the permissions array
* @param array $permissions
* @return $this
*/
public function permissions($permissions = []) {
if (func_num_args() > 0) {
$this->permissions = $permissions;
return $this;
}
return $this->permissions;
}
/**
* Applies a filter to the payload of this message.
* When a filter returns null then the payload is set to null
* @param filter $filter
*/
public function apply_filter(?filter $filter) {
if ($filter !== null && is_array($this->payload)) {
foreach ($this->payload as $key => $value) {
$result = ($filter)($key, $value);
// Check if a filter requires dropping the payload
if ($result === null) {
$this->payload = null;
return;
}
// Remove a key if filter does not pass
elseif(!$result) {
unset($this->payload[$key]);
}
}
}
}
/**
* Gets or sets the domain UUID
* @param string $domain_uuid
* @return $this or $domain_uuid
*/
public function domain_uuid($domain_uuid = '') {
if (func_num_args() > 0) {
$this->domain_uuid = $domain_uuid;
return $this;
}
return $this->domain_uuid;
}
/**
* Gets or sets the domain name
* @param string $domain_name
* @return $this or $domain_name
*/
public function domain_name($domain_name = '') {
if (func_num_args() > 0) {
$this->domain_name = $domain_name;
return $this;
}
return $this->domain_name;
}
/**
* Gets or Sets the service name
* If no parameters are provided then the service_name is returned. If the service name is provided, then the
* topic is set to the value provided.
* @param string $topic
* @return $this
*/
public function topic($topic = null) {
if (func_num_args() > 0) {
$this->topic = $topic;
return $this;
}
return $this->topic;
}
/**
* Gets or sets the token array using the key values of 'name' and 'hash'
* @param array $token_array
* @return array|$this
* @see token_name
* @see token_hash
*/
public function token($token_array = []) {
if (func_num_args() > 0) {
$this->token_name($token_array['name'] ?? '')->token_hash($token_array['hash'] ?? '');
return $this;
}
return ['name' => $this->token_name, 'hash' => $this->token_hash];
}
/**
* Sets the token name
* @param string $token_name
* @return $this
* @see token_hash
*/
public function token_name($token_name = '') {
if (func_num_args() > 0) {
$this->token_name = $token_name;
return $this;
}
return $this->token_name;
}
/**
* Gets or sets the status code of this message
* @param int $status_code
* @return $this
*/
public function status_code($status_code = '') {
if (func_num_args() > 0) {
$this->status_code = $status_code;
return $this;
}
return $this->status_code;
}
/**
* Gets or sets the resource id
* @param type $resource_id
* @return $this
*/
public function resource_id($resource_id = null) {
if (func_num_args() > 0) {
$this->resource_id = $resource_id;
return $this;
}
return $this->resource_id;
}
/**
* Gets or sets the request ID
* @param type $request_id
* @return $this
*/
public function request_id($request_id = null) {
if (func_num_args() > 0) {
$this->request_id = $request_id;
return $this;
}
return $this->request_id;
}
/**
* Gets or sets the status string
* @param type $status_string
* @return $this
*/
public function status_string( $status_string = null) {
if (func_num_args() > 0) {
$this->status_string = $status_string;
return $this;
}
return $this->status_string;
}
/**
* Gets or sets the token hash
* @param type $token_hash
* @return $this
* @see token_name
*/
public function token_hash($token_hash = null) {
if (func_num_args() > 0) {
$this->token_hash = $token_hash;
return $this;
}
return $this->token_hash;
}
/**
* Convert the 'statusString' key that comes from javascript
* @param type $status_string
* @return type
*/
public function statusString($status_string = '') {
return $this->status_string($status_string);
}
/**
* Convert the 'statusCode' key that comes from javascript
* @param type $status_code
* @return $this
*/
public function statusCode($status_code = 200) {
return $this->status_code($status_code);
}
/**
* Unwrap a JSON message to an associative array
* @param string $json_string
* @return array
*/
public static function unwrap($json_string = '') {
return json_decode($json_string, true);
}
/**
* Helper function to respond with a connected message
* @param type $request_id
* @return type
*/
public static function connected($request_id = '') {
return static::request_authentication($request_id);
}
/**
* Helper function to respond with a authentication message
* @param type $request_id
* @return type
*/
public static function request_authentication($request_id = '') {
$class = static::class;
return (new $class())
->request_id($request_id)
->service_name('authentication')
->status_code(407)
->status_string('Authentication Required')
->topic('authenticate')
->__toString()
;
}
/**
* Helper function to respond with a bad request message
* @param type $request_id
* @param type $service
* @param type $topic
* @return type
*/
public static function request_is_bad($request_id = '', $service = '', $topic = '') {
$class = static::class;
return (new $class())
->request_id($request_id)
->service_name($service)
->topic($topic)
->status_code(400)
->__toString()
;
}
/**
* Helper function to respond with an authenticated message
* @param type $request_id
* @param type $service
* @param type $topic
* @return type
*/
public static function request_authenticated($request_id = '', $service = '', $topic = 'authenticated') {
$class = static::class;
return (new $class())
->request_id($request_id)
->service_name($service)
->topic($topic)
->status_code(200)
->status_string('OK')
->__toString()
;
}
/**
* Helper function to respond with an unauthorized request message
* @param type $request_id
* @param type $service
* @param type $topic
* @return type
*/
public static function request_unauthorized($request_id = '', $service = '', $topic = 'unauthorized') {
$class = static::class;
return (new $class())
->request_id($request_id)
->service_name($service)
->topic($topic)
->status_code(401)
->__toString()
;
}
/**
* Helper function to respond with a forbidden message
* @param type $request_id
* @param type $service
* @param type $topic
* @return type
*/
public static function request_forbidden($request_id = '', $service = '', $topic = 'forbidden') {
$class = static::class;
return (new $class())
->request_id($request_id)
->service_name($service)
->topic($topic)
->status_code(403)
->__toString()
;
}
/**
* Returns a websocket_message object (or child object) using the provided JSON string or JSON array
* @param string|array $websocket_message_json JSON array or JSON string
* @return static|null Returns a new websocket_message object (or child object)
* @throws \InvalidArgumentException
*/
public static function create_from_json_message($websocket_message_json) {
if (empty($websocket_message_json)) {
// Nothing to do
return null;
} elseif (is_string($websocket_message_json)) {
$json_array = json_decode($websocket_message_json, true);
} elseif (is_array($websocket_message_json)) {
$json_array = $websocket_message_json;
} else {
throw new \InvalidArgumentException("create_from_websocket_message_json expected string or array but got " . gettype($websocket_message_json));
}
return new static($json_array);
}
}

View File

@@ -0,0 +1,571 @@
<?php
declare(strict_types=1);
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* Simple WebSocket server class. Supporting chunking, PING, PONG.
*
* The on_connect, on_disconnect, on_message events require a function to be passed
* so the websocket_server can call that function when the specific events occur. Each
* of the functions must accept one parameter for the resource that the event occurred on.
* Supports multiple clients and broadcasts messages from one to all others.
*/
class websocket_server {
/**
* Address to bind to. (Default 8080)
* @var string
*/
protected $address;
/**
* Port to bind to. (Default 0.0.0.0 - all PHP detected IP addresses of the system)
* @var int
*/
protected $port;
/**
* Tracks if the server is running
* @var bool
*/
protected $running;
/**
* Resource or stream of the server socket binding
* @var resource|stream
*/
protected $server_socket;
/**
* List of connected client sockets
* @var array
*/
protected $clients;
/**
* Used to track on_message events
* @var array
*/
private $message_callbacks;
/**
* Used to track on_connect events
* @var array
*/
private $connect_callbacks;
/**
* Used to track on_disconnect events
* @var array
*/
private $disconnect_callbacks;
/**
* Used to track switch listeners or other socket connection types
* @var array
*/
private $listeners;
/**
* Creates a websocket_server instance
* @param string $address IP to bind (default 0.0.0.0)
* @param int $port TCP port (default 8080)
*/
public function __construct(string $address = '127.0.0.1', int $port = 8080) {
$this->running = false;
$this->address = $address;
$this->port = $port;
// Initialize arrays
$this->listeners = [];
$this->clients = [];
$this->message_callbacks = [];
$this->connect_callbacks = [];
$this->disconnect_callbacks = [];
}
private function debug(string $message) {
self::log($message, LOG_DEBUG);
}
private function warn(string $message) {
self::log($message, LOG_WARNING);
}
private function error(string $message) {
self::log($message, LOG_ERR);
}
private function info(string $message) {
self::log($message, LOG_INFO);
}
/**
* Starts server: accepts new clients, reads frames, and broadcasts messages.
* @returns int A non-zero indicates an abnormal termination
*/
public function run(): int {
$this->server_socket = stream_socket_server("tcp://{$this->address}:{$this->port}", $errno, $errstr);
if (!$this->server_socket) {
throw new \RuntimeException("Cannot bind socket ({$errno}): {$errstr}");
}
stream_set_blocking($this->server_socket, false);
// We are now running
$this->running = true;
while ($this->running) {
$listeners = array_column($this->listeners, 0);
$read = array_merge([$this->server_socket], $listeners, $this->clients);
$write = $except = [];
// Server connection issue
if (false === stream_select($read, $write, $except, null)) {
$this->running = false;
break;
}
// new connection
if (in_array($this->server_socket, $read, true)) {
$conn = @stream_socket_accept($this->server_socket, 0);
if ($conn) {
// complete handshake on blocking socket
stream_set_blocking($conn, true);
$this->handshake($conn);
// switch to non-blocking for further reads
stream_set_blocking($conn, false);
// add them to the websocket list
$this->clients[] = $conn;
// notify websocket on_connect listeners
$this->trigger_connect($conn);
}
continue;
}
// handle other sockets
foreach ($read as $client_socket) {
// check switch listeners
if (in_array($client_socket, $listeners, true)) {
// Process external listeners
$index = array_search($client_socket, $listeners, true);
try {
//send the switch event to the registered callback function
call_user_func($this->listeners[$index][1], $client_socket);
} catch (\socket_disconnected_exception $s) {
$this->info("[INFO] Removed client $s->id from list");
$success = $this->disconnect_client($client_socket);
// By attaching the socket_disconnect error message to \socket_exception we can see where something went wrong
if (!$success)
throw new socket_exception('Socket does not exist in tracking array', 256, $s);
}
continue;
}
// Process web socket client communication
$message = $this->receive_frame($client_socket);
if ($message === '') {
continue;
}
$this->trigger_message($client_socket, $message);
}
}
}
/**
* Add a non-blocking socket to listen for traffic on
* @param resource $socket
* @param callable $on_data_ready_callback Callable function to call when data arrives on the socket
* @throws \InvalidArgumentException
*/
public function add_listener($socket, callable $on_data_ready_callback) {
if (!is_callable($on_data_ready_callback)) {
throw new \InvalidArgumentException('The callable on_data_ready_callback must be a valid callable function');
}
$this->listeners[] = [$socket, $on_data_ready_callback];
}
/**
* Returns true if there are connected web socket clients.
* @return bool
*/
public function has_clients(): bool {
return !empty($this->clients);
}
/**
* When a web socket message is received the $on_message_callback function is called.
* Multiple on_message functions can be specified.
* @param callable $on_message_callback Callable function to call when data arrives on the socket
* @throws InvalidArgumentException
*/
public function on_message(callable $on_message_callback) {
if (!is_callable($on_message_callback)) {
throw new \InvalidArgumentException('The callable on_message_callback must be a valid callable function');
}
$this->message_callbacks[] = $on_message_callback;
}
/**
* Calls all the on_message functions
* @param resource $socket
* @param string $message
* @return void
*/
private function trigger_message($socket, string $message) {
foreach ($this->message_callbacks as $callback) {
$response = call_user_func($callback, $socket, $message);
if ($response !== null) {
$this->send($socket, $response);
}
return;
}
}
/**
* When a web socket handshake has completed, the $on_connect_callback function is called.
* Multiple on_connect functions can be specified.
* @param callable $on_connect_callback Callable function to call when a new connection occurs.
* @throws InvalidArgumentException
*/
public function on_connect(callable $on_connect_callback) {
if (!is_callable($on_connect_callback)) {
throw new \InvalidArgumentException('The callable on_connect_callback must be a valid callable function');
}
$this->connect_callbacks[] = $on_connect_callback;
}
/**
* Calls all the on_connect functions
* @param resource $socket
*/
private function trigger_connect($socket) {
foreach ($this->connect_callbacks as $callback) {
$response = call_user_func($callback, $socket);
if ($response !== null) {
self::send($socket, $response);
}
}
}
/**
* When a web socket has disconnected, the $on_disconnect_callback function is called.
* Multiple functions can be specified with subsequent calls
* @param string|callable $on_disconnect_callback Callable function to call when a socket disconnects. The function must accept a single parameter for the socket that was disconnected.
* @throws InvalidArgumentException
*/
public function on_disconnect($on_disconnect_callback) {
if (!is_callable($on_disconnect_callback)) {
throw new \InvalidArgumentException('The callable on_disconnect_callback must be a valid callable function');
}
$this->disconnect_callbacks[] = $on_disconnect_callback;
}
/**
* Calls all the on_disconnect_callback functions
* @param type $socket
*/
private function trigger_disconnect($socket) {
foreach ($this->disconnect_callbacks as $callback) {
call_user_func($callback, $socket);
}
}
/**
* Returns the socket used in the server connection
* @return resource
*/
public function get_socket() {
return $this->server_socket;
}
/**
* Remove a client socket on disconnect.
* @return bool Returns true on client disconnect and false when the client is not found in the tracking array
*/
protected function disconnect_client($socket, $error = null): bool {
$index = array_search($resource, $this->clients, true);
if ($index !== false) {
self::disconnect($resource);
unset($this->clients[$index]);
$this->trigger_disconnect($socket);
return true;
}
return false;
}
/**
* Sends a disconnect frame with no payload
* @param type $resource
*/
public static function disconnect($resource) {
if (is_resource($resource)) {
//send OPCODE
@fwrite($resource, "\x88\x00"); // 0x88 = close frame, no payload
@fclose($resource);
}
}
/**
* Performs web socket handshake on new connection.
* @param type $socket Socket to perform the handshake on.
*/
protected function handshake($socket) {
// ensure blocking to read full header
stream_set_blocking($socket, true);
$request_header = '';
while (($line = fgets($socket)) !== false) {
$request_header .= $line;
if (rtrim($line) === '') {
break;
}
}
if (!preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $request_header, $matches)) {
throw new \RuntimeException("Invalid WebSocket handshake");
}
$key = trim($matches[1]);
$accept_key = base64_encode(
sha1($key . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true)
);
$response_header = "HTTP/1.1 101 Switching Protocols\r\n"
. "Upgrade: websocket\r\n"
. "Connection: Upgrade\r\n"
. "Sec-WebSocket-Accept: {$accept_key}\r\n\r\n";
fwrite($socket, $response_header);
}
/**
* Read specific number of bytes from a web socket
* @param resource $socket
* @param int $length
* @return string
*/
private function read_bytes($socket, int $length): string {
$data = '';
while (strlen($data) < $length && is_resource($socket)) {
$chunk = fread($socket, $length - strlen($data));
if ($chunk === false || $chunk === '' || !is_resource($socket)) {
$this->disconnect_client($socket);
return '';
}
$data .= $chunk;
}
return $data;
}
/**
* Reads a web socket data frame and converts it to a regular string
* @param resource $socket
* @return string
*/
private function receive_frame($socket): string {
if (!is_resource($socket)) {
throw new \RuntimeException("Not connected");
}
$final_frame = false;
$payload_data = '';
while (!$final_frame) {
$header = $this->read_bytes($socket, 2);
if ($header === null)
return null;
$byte1 = ord($header[0]);
$byte2 = ord($header[1]);
$final_frame = ($byte1 >> 7) & 1;
$opcode = $byte1 & 0x0F;
$masked = ($byte2 >> 7) & 1;
$payload_len = $byte2 & 0x7F;
// Extended payload length
if ($payload_len === 126) {
$extended = $this->read_bytes($socket, 2);
if ($extended === null)
return null;
$payload_len = unpack('n', $extended)[1];
} elseif ($payload_len === 127) {
$extended = $this->read_bytes($socket, 8);
if ($extended === null)
return null;
$payload_len = 0;
for ($i = 0; $i < 8; $i++) {
$payload_len = ($payload_len << 8) | ord($extended[$i]);
}
}
// Read mask
$mask = '';
if ($masked) {
$mask = $this->read_bytes($socket, 4);
if ($mask === null)
return null;
}
// Read payload
$payload = $this->read_bytes($socket, $payload_len);
if ($payload === null) {
$this->error("[ERROR] Incomplete payload received");
return null;
}
// Unmask if needed
if ($masked) {
$unmasked = '';
for ($i = 0; $i < $payload_len; $i++) {
$unmasked .= $payload[$i] ^ $mask[$i % 4];
}
$payload = $unmasked;
}
// Handle control frames
switch ($opcode) {
case 0x9: // PING
// Respond with PONG using same payload
$this->send_control_frame(0xA, $payload);
$this->info("Received PING, sent PONG");
continue; // Skip returning PING
case 0x8: // CLOSE frame
$this->info("Received CLOSE frame, connection will be closed.");
$this->disconnect_client($socket);
return null;
case 0xA: // PONG
$this->info("Received PONG");
$reason = $this->read_bytes($socket, 2);
$this->info("Reason: $reason");
continue; // Skip returning PONG
case 0x1: // TEXT frame
case 0x0: // Continuation frame
$payload_data .= $payload;
break;
default:
$this->warn("Unsupported opcode: $opcode");
return null;
}
}
$meta = stream_get_meta_data($socket);
if ($meta['unread_bytes'] > 0) {
$this->warn("{$meta['unread_bytes']} bytes left in socket after read");
}
return $payload_data;
}
/**
* Send text frame to client. If the socket connection is not a valid resource, the send
* method will fail silently and return false.
* @param resource $resource The socket or resource id to communicate on.
* @param string|null $payload The message to send to the clients. Sending null as the message sends a close frame packet.
* @return bool True if message was sent on the provided resource or false if there was an error.
*/
public static function send($resource, ?string $payload): bool {
if (!is_resource($resource)) {
throw new \socket_disconnected_exception($resource);
}
// Check for a null message and send a disconnect frame
if ($payload === null) {
// 88 = CLOSE, 00 = NO REASON
@fwrite($resource, chr(0x88) . chr(0x00));
return true;
}
$chunk_size = 4096; // 4 KB
$payload_len = strlen($payload);
$offset = 0;
$first = true;
while ($offset < $payload_len) {
$remaining = $payload_len - $offset;
$chunk = substr($payload, $offset, min($chunk_size, $remaining));
$chunk_len = strlen($chunk);
// Determine FIN bit and opcode
$fin = ($offset + $chunk_size >= $payload_len) ? 0x80 : 0x00; // 0x80 if final
$opcode = $first ? 0x1 : 0x0; // text for first frame, continuation for rest
$first = false;
// Build header
$header = chr($fin | $opcode);
// Payload length
if ($chunk_len <= 125) {
$header .= chr($chunk_len);
} elseif ($chunk_len <= 65535) {
$header .= chr(126) . pack('n', $chunk_len);
} else {
// 64-bit big-endian
$length_bytes = '';
for ($i = 7; $i >= 0; $i--) {
$length_bytes .= chr(($chunk_len >> ($i * 8)) & 0xFF);
}
$header .= chr(127) . $length_bytes;
}
// Send frame (header + chunk)
$bytes_written = @fwrite($resource, $header . $chunk);
if ($bytes_written === false) {
return false;
}
$offset += $chunk_len;
}
return true;
}
/**
* Get the IP and port of the connected remote system.
* @param socket $socket The socket stream of the connection
* @return array An associative array of remote_ip and remote_port
*/
public static function get_remote_info($socket): array {
[$remote_ip, $remote_port] = explode(':', stream_socket_get_name($socket, true), 2);
return ['remote_ip' => $remote_ip, 'remote_port' => $remote_port];
}
/**
* Print socket information
* @param resource $resource
* @param bool $return If you would like to capture the output of print_r(), use the return parameter. When this
* parameter is set to true, print_r() will return the information rather than print it.
*/
public static function print_stream_info($resource, $return = false) {
if (is_resource($resource)) {
$meta_data = stream_get_meta_data($resource);
[$remote_ip, $remote_port] = explode(':', stream_socket_get_name($resource, true), 2);
$meta_data['remote_addr'] = $remote_ip;
$meta_data['remote_port'] = $remote_port;
if ($return)
return $meta_data;
print_r($meta_data);
}
}
}

View File

@@ -0,0 +1,917 @@
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
/**
* Description of websocket_service
*
* @author Tim Fry <tim@fusionpbx.com>
*/
class websocket_service extends service {
/**
* Address to bind to. (Default 8080)
* @var string
*/
protected $ip;
/**
* Port to bind to. (Default 0.0.0.0 - all PHP detected IP addresses of the system)
* @var int
*/
protected $port;
/**
* Resource or stream of the server socket binding
* @var resource|stream
*/
protected $server_socket;
/**
* List of connected client sockets
* @var array
*/
protected $clients;
/**
* Used to track on_message events
* @var array
*/
protected $message_callbacks;
/**
* Used to track on_connect events
* @var array
*/
protected $connect_callbacks;
/**
* Used to track on_disconnect events
* @var array
*/
protected $disconnect_callbacks;
/**
* Used to track switch listeners or other socket connection types
* @var array
*/
protected $listeners;
public static $logger;
/**
* Subscriber Objects
* @var subscriber
*/
protected $subscribers;
/**
* Array of registered services
* @var array
*/
private $services;
public function is_debug_enabled(): bool {
return parent::$log_level === LOG_DEBUG;
}
/**
* Reload settings
* @return void
* @throws \RuntimeException
* @access protected
*/
protected function reload_settings(): void {
// Initialize tracking arrays
$this->listeners = [];
$this->clients = [];
$this->message_callbacks = [];
$this->connect_callbacks = [];
$this->disconnect_callbacks = [];
$this->subscribers = [];
$settings = new settings(['database' => database::new(['config' => config::load()])]);
$ip = $settings->get('websocket_server', 'bind_ip_address', '127.0.0.1');
if ($ip === null) {
throw new \RuntimeException("ERROR: Bind IP address not specified");
}
// Save the setting
$this->ip = $ip;
$port = intval($settings->get('websocket_server', 'bind_port', 8080));
if (empty($port)) {
throw new \RuntimeException("ERROR: Port address not specified");
}
// Save the setting
$this->port = $port;
}
/**
* Display the version on the console
* @return void
* @access protected
*/
protected static function display_version(): void {
echo "Web Socket Service Version 1.00\n";
}
/**
* Set extra command options from the command line
* @access protected
*/
protected static function set_command_options() {
//TODO: ip address
//TODO: port
}
/**
* Trigger disconnect callbacks
*/
protected function update_connected_clients() {
$disconnected_clients = [];
foreach ($this->clients as $index => $resource) {
if (!is_resource($resource) || feof($resource)) {
// Ensure resource is free
unset($this->clients[$index]);
$disconnected_clients[] = $resource;
}
}
if (!empty($disconnected_clients)) {
foreach ($disconnected_clients as $dis_con) {
$this->trigger_disconnect($dis_con);
}
}
}
private function get_subscriber_from_socket_id($socket): ?subscriber {
$subscriber = null;
// Get the subscriber based on their socket ID
foreach ($this->subscribers as $s) {
if ($s->equals($socket)) {
$subscriber = $s;
break;
}
}
return $subscriber;
}
private function authenticate_subscriber(subscriber $subscriber, websocket_message $message) {
$this->info("Authenticating client: $subscriber->id");
// Already authenticated
if ($subscriber->is_authenticated()) {
return true;
}
// Authenticate their token
if ($subscriber->authenticate_token($message->token)) {
$subscriber->send(websocket_message::request_authenticated($message->request_id, $message->service));
if ($subscriber->is_service()) {
$this->info("Service $subscriber->id authenticated");
$this->services[$subscriber->service_name()] = $subscriber;
} else {
$this->info("Client $subscriber->id authenticated");
$this->info("Setting permissions on $subscriber->id");
$subscriptions = $subscriber->subscribed_to();
foreach ($subscriber->subscribed_to() as $subscribed_to) {
if (isset($this->services[$subscribed_to])) {
$service = $this->services[$subscribed_to];
if (is_a($service, 'websocket_service_interface', true)) {
$class = $service->get_service_name();
$filter = $class::create_filter_chain_for($subscriber);
if ($filter !== null) {
$subscriber->set_filter($filter);
}
}
$this->info("Set permissions for $subscriber->id for service " . $service->service_name());
}
}
}
} else {
$subscriber->send(websocket_message::request_unauthorized($message->request_id, $message->service));
// Disconnect them
$this->handle_disconnect($subscriber->socket_id());
}
return;
}
private function broadcast_service_message(subscriber $broadcaster, ?websocket_message $message = null) {
$this->debug("Processing Broadcast");
// Ensure we have something to do
if ($message === null) {
$this->warn("Unable to broadcast empty message");
return;
}
$subscribers = array_filter($this->subscribers, function ($subscriber) use ($broadcaster) {
return $subscriber->not_equals($broadcaster);
});
if (empty($subscribers)) {
$this->debug("No subscribers to broadcast message to");
return;
}
// Ensure the service is not responding to a specific request
$request_id = $message->request_id;
if (empty($request_id)) {
// Get the service name from the message
$service_name = $message->service_name;
// Filter subscribers to only the ones subscribed to the service name
$send_to = $this->filter_subscribers($subscribers, $message, $service_name);
// Send the message to the filtered subscribers
foreach ($send_to as $subscriber) {
try {
// Notify of the message we are broadcasting
$this->debug("Broadcasting message '" . $message->payload['event_name'] . "' for service '" . $message->service_name . "' to subscriber $subscriber->id");
$message->apply_filter($subscriber->get_filter());
$subscriber->send_message($message);
} catch (subscriber_token_expired_exception $ste) {
$this->info("Subscriber $ste->id token expired");
// Subscriber token has expired so disconnect them
$this->handle_disconnect($subscriber->socket_id());
}
}
}
// Route a specific request from a service back to a subscriber
else {
// Get the subscriber object hash
$object_id = $message->resource_id;
if (isset($this->subscribers[$object_id])) {
$subscriber = $this->subscribers[$object_id];
// Remove the resource_id from the message
$message->resource_id('');
// TODO: Fix removal of request_id
$message->request_id('');
// Return the requested results back to the subscriber
$subscriber->send_message($message);
}
}
return;
}
/**
* Filters subscribers based on the service name given
* @param array $subscribers
* @param websocket_message $message
* @param string $service_name
* @return array List of subscriber objects or an empty array if there are no subscribers to that service name
*/
private function filter_subscribers(array $subscribers, websocket_message $message, string $service_name): array {
$filtered = [];
foreach ($subscribers as $subscriber) {
$caller_context = strtolower($message->caller_context ?? '');
if (!empty($caller_context) && $subscriber->has_subscribed_to($service_name) && ($subscriber->show_all || $caller_context === $subscriber->domain_name || $caller_context === 'public' || $caller_context === 'default'
)
) {
$filtered[] = $subscriber;
} else {
if ($subscriber->has_subscribed_to($service_name))
$filtered[] = $subscriber;
}
}
return $filtered;
}
/**
* Create a subscriber for each connection
* @param resource $socket
* @return void
*/
private function handle_connect($socket) {
// We catch only the socket disconnection exception as there is a general try/catch already
try {
$subscriber = new subscriber($socket, [websocket_service::class, 'send']);
$this->subscribers[$subscriber->id] = $subscriber;
$subscriber->send(websocket_message::connected());
} catch (\socket_disconnected_exception $sde) {
$this->warning("Client $sde->id disconnected during connection");
// remove the connected client
$this->handle_disconnect($sde->id);
}
return;
}
/**
* Web socket client disconnected from the server or this service has requested a disconnect from the subscriber
* @param subscriber|resource|int|string $object_or_resource_or_id
*/
private function handle_disconnect($object_or_resource_or_id) {
//
// Notify user
//
$this->info("Disconnecting subscriber: '$object_or_resource_or_id'");
//
// Search for the socket using the equals method in subscriber
//
$subscriber = null;
/* PHP 8 syntax: $subscriber = array_find($this->subscribers, fn ($subscriber) => $subscriber->equals($socket_id)); */
// Find the subscriber in our array
foreach ($this->subscribers as $s) {
if ($s->equals($object_or_resource_or_id)) {
$subscriber = $s;
}
}
// We have found our subscriber to be disconnected
if ($subscriber !== null) {
// If they are still connected then disconnect them with the proper disconnect
if ($subscriber->is_connected()) {
$subscriber->disconnect();
}
// remove from the subscribers list
unset($this->subscribers[$subscriber->id]);
// remove from services
unset($this->services[$subscriber->service_name()]);
// notify user
$this->info("Disconnected subscriber: '$subscriber->id'");
}
// show the list for debugging
$this->debug("Current Subscribers: " . implode(', ', array_keys($this->subscribers)));
}
/**
* When a message event occurs, send to all the subscribers
* @param resource $socket
* @param mixed $data
*/
private function handle_message($socket, $data) {
$subscriber = $this->get_subscriber_from_socket_id($socket);
// Ensure we have someone to talk to
if ($subscriber === null)
return;
$this->debug("Received message from " . $subscriber->id);
// Convert the message from json string to a message array
$json_array = json_decode($data, true);
if (is_array($json_array))
try {
// Check for an authenticating subscriber
if ($json_array['service'] === 'authentication') {
$this->authenticate_subscriber($subscriber, new websocket_message($json_array));
return;
}
// Create a websocket_message object using the json data sent
$message = websocket_message::create_from_json_message($json_array);
if ($message === null) {
return;
}
// Reject subscribers that do not have not validated
if (!$subscriber->is_authenticated()) {
$subscriber->send(websocket_message::request_authentication($message->request_id()));
return;
}
// If the message comes from a service, broadcast it to all subscribers subscribed to that service
if ($subscriber->is_service()) {
$this->debug("Message is from service");
$this->broadcast_service_message($subscriber, $message);
return;
}
// Message is from the client so check the service_name that needs to get the message
if (!empty($message->service_name())) {
$this->handle_client_message($subscriber, $message);
} else {
// Message does not have a service name
$this->warning("The message does not have a service name. All messages must have a service name to direct their query to.");
$subscriber->send(websocket_message::request_is_bad($message->id, 'INVALID', $message->topic));
}
} catch (socket_disconnected_exception $sde) {
$this->handle_disconnect($sde->id);
}
}
private function handle_client_message(subscriber $subscriber, websocket_message $message) {
//find the service with that name
foreach ($this->subscribers as $service) {
//when we find the service send the request
if ($service->service_equals($message->service_name())) {
//attach the current subscriber permissions so the service can verify
$message->permissions($subscriber->get_permissions());
//attach the domain name
$message->domain_name($subscriber->get_domain_name());
//attach the client id so we can track the request
$message->resource_id = $subscriber->id;
//send the modified web socket message to the service
$service->send((string) $message);
//continue searching for service providers
continue;
}
}
}
/**
* Runs the web socket server binding to the ip and port set in default settings
* The run method will stop if the SIG_TERM or SIG_HUP signal is processed in the parent
* @return int
* @throws \RuntimeException
* @throws socket_exception
*/
public function run(): int {
// Reload all settings and initialize object properties
$this->reload_settings();
$this->server_socket = stream_socket_server("tcp://{$this->ip}:{$this->port}", $errno, $errstr);
if (!$this->server_socket) {
throw new \RuntimeException("Cannot bind socket ({$errno}): {$errstr}");
}
stream_set_blocking($this->server_socket, false);
//
// Register handlers
// The handlers can be registered outside this class because they are standard callbacks
//
$this->on_connect([self::class, 'handle_connect']);
$this->on_disconnect([self::class, 'handle_disconnect']);
$this->on_message([self::class, 'handle_message']);
$stream_select_tries = 0;
while ($this->running) {
//
// Merge all sockets to a single array
//
$read = array_merge([$this->server_socket], $this->clients);
$write = $except = [];
//$this->debug("Waiting on event. Connected Clients: (".count($this->clients).")", LOG_DEBUG);
//
// Wait for activity on the sockets and timeout about 3 times per second
//
$result = stream_select($read, $write, $except, 0, 333333);
if ($result === false) {
// Check for error status 3 times in a row
if (++$stream_select_tries > 3) {
throw new \RuntimeException("Error occured reading socket");
}
// There was likely a disconnect during the wait state
$this->update_connected_clients();
continue;
}
// Reset stream_select counter
$stream_select_tries = 0;
if ($result === 0) {
// Timeout no activity
continue;
}
//
// Handle a socket activity
//
foreach ($read as $client_socket) {
// new connection
if ($client_socket === $this->server_socket) {
$conn = @stream_socket_accept($this->server_socket, 0);
if ($conn) {
// complete handshake on blocking socket
stream_set_blocking($conn, true);
$this->handshake($conn);
// switch to non-blocking for further reads
stream_set_blocking($conn, false);
// add them to the websocket list
$this->clients[] = $conn;
// notify websocket on_connect listeners
$this->trigger_connect($conn);
continue;
}
}
// Process web socket client communication
$message = $this->receive_frame($client_socket);
if ($message === '') {
$this->debug("Empty message");
continue;
}
// Check for control frame
if (strlen($message) === 2) {
$value = bin2hex($message);
if ($value === '03e9') {
$this->disconnect_client($client_socket);
continue;
}
$this->debug("UNKNOWN CONTROL FRAME: '$value'", LOG_ERR);
die();
}
try {
$this->trigger_message($client_socket, $message);
} catch (subscriber_exception $se) {
//
// Here we are catching any type of subscriber exception and displaying the error in the log.
// This will disconnect the subscriber as we no longer know the state of the object.
//
//
// Get the error details
//
$subscriber_id = $se->getSubscriberId();
$message = $se->getMessage();
$code = $se->getCode();
$file = $se->getFile();
$line = $se->getLine();
//
// Dump the details in the log
//
$this->err("ERROR FROM $subscriber_id: $message ($code) IN FILE $file (Line: $line)");
$this->err($se->getTraceAsString());
//
// Disconnect the subscriber
//
$subscriber = $this->subscribers[$subscriber_id] ?? null;
if ($subscriber !== null) $this->disconnect_client($subscriber->socket());
}
}
}
}
/**
* Overrides the parent class to shutdown all sockets
* @override service
*/
public function __destruct() {
//disconnect all clients
foreach ($this->clients as $socket) {
$this->disconnect_client($socket);
}
//finish destruct using the parent
parent::__destruct();
}
public function get_open_sockets(): array {
return $this->clients;
}
/**
* Returns true if there are connected web socket clients.
* @return bool
*/
public function has_clients(): bool {
return !empty($this->clients);
}
/**
* When a web socket message is received the $on_message_callback function is called.
* Multiple on_message functions can be specified.
* @param callable $on_message_callback
* @throws InvalidArgumentException
*/
public function on_message(callable $on_message_callback) {
if (!is_callable($on_message_callback)) {
throw new \InvalidArgumentException('The callable on_message_callback must be a valid callable function');
}
$this->message_callbacks[] = $on_message_callback;
}
/**
* Calls all the on_message functions
* @param resource $resource
* @param string $message
* @return void
* @access protected
*/
protected function trigger_message($resource, string $message) {
foreach ($this->message_callbacks as $callback) {
call_user_func($callback, $resource, $message);
return;
}
}
/**
* When a web socket handshake has completed, the $on_connect_callback function is called.
* Multiple on_connect functions can be specified.
* @param callable $on_connect_callback
* @throws InvalidArgumentException
*/
public function on_connect(callable $on_connect_callback) {
if (!is_callable($on_connect_callback)) {
throw new \InvalidArgumentException('The callable on_connect_callback must be a valid callable function');
}
$this->connect_callbacks[] = $on_connect_callback;
}
/**
* Calls all the on_connect functions
* @param resource $resource
* @access protected
*/
protected function trigger_connect($resource) {
foreach ($this->connect_callbacks as $callback) {
call_user_func($callback, $resource);
}
}
/**
* When a web socket has disconnected, the $on_disconnect_callback function is called.
* Multiple functions can be specified with subsequent calls
* @param string|callable $on_disconnect_callback
* @throws InvalidArgumentException
*/
public function on_disconnect($on_disconnect_callback) {
if (!is_callable($on_disconnect_callback)) {
throw new \InvalidArgumentException('The callable on_disconnect_callback must be a valid callable function');
}
$this->disconnect_callbacks[] = $on_disconnect_callback;
}
/**
* Calls all the on_disconnect_callback functions
* @param resource $socket
* @access protected
*/
protected function trigger_disconnect($socket) {
foreach ($this->disconnect_callbacks as $callback) {
call_user_func($callback, $socket);
}
}
/**
* Returns the socket used in the server connection
* @return resource
*/
public function get_socket() {
return $this->server_socket;
}
/**
* Remove a client socket on disconnect.
* @param resource $resource Resource for the socket connection
* @return bool Returns true on client disconnect and false when the client is not found in the tracking array
* @access protected
*/
protected function disconnect_client($resource): bool {
// Close the socket
if (is_resource($resource)) {
@fwrite($resource, chr(0x88) . chr(0x00)); // 0x88 = close frame, no reason
@fclose($resource);
}
$this->debug("OLD Client List: " . var_dump($this->clients, true));
// Clean out the array
$clients = array_filter($this->clients, function ($resource) {
return is_resource($resource) && !feof($resource);
});
$this->debug("NEW Client List: " . var_dump($clients, true));
// Compare to the original array
$diff = array_diff($this->clients, $clients);
$this->debug("DIFF Client List: " . var_dump($diff, true));
// Replace the old list with only the connected ones
$this->clients = $clients;
// Trigger the disconnect for each closed socket
foreach ($diff as $socket) {
// We must check before closing the socket that it is a resource or a fatal error will occur
if (is_resource($socket)) {
@fwrite($resource, "\x88\x00"); // 0x88 = close frame, no payload
@fclose($socket);
}
// Trigger the disconnect so any hooks can clean up their lists
$this->trigger_disconnect($socket);
}
return true;
}
/**
* Performs web socket handshake on new connection.
* @access protected
*/
protected function handshake($resource): void {
// ensure blocking to read full header
stream_set_blocking($resource, true);
$request_header = '';
while (($line = fgets($resource)) !== false) {
$request_header .= $line;
if (rtrim($line) === '') {
break;
}
}
if (!preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $request_header, $matches)) {
throw new \RuntimeException("Invalid WebSocket handshake");
}
$key = trim($matches[1]);
$accept_key = base64_encode(
sha1($key . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true)
);
$response_header = "HTTP/1.1 101 Switching Protocols\r\n"
. "Upgrade: websocket\r\n"
. "Connection: Upgrade\r\n"
. "Sec-WebSocket-Accept: {$accept_key}\r\n\r\n";
fwrite($resource, $response_header);
}
/**
* Read specific number of bytes from a websocket
* @param resource $socket
* @param int $length
* @return string
*/
private function read_bytes($socket, int $length): string {
$data = '';
while (strlen($data) < $length && is_resource($socket)) {
$chunk = fread($socket, $length - strlen($data));
if ($chunk === false || $chunk === '' || !is_resource($socket)) {
//$this->disconnect_client($socket);
return '';
}
$data .= $chunk;
}
return $data;
}
/**
* Reads a websocket data frame and converts it to a regular string
* @param resource $socket
* @return string
*/
private function receive_frame($socket): string {
// Read first two header bytes
$hdr = $this->read_bytes($socket, 2);
// Ensure we have the correct number of bytes
if (strlen($hdr) !== 2) {
$this->warning('Header is empty!');
$this->update_connected_clients();
return '';
}
$bytes = unpack('Cfirst/Csecond', $hdr);
$fin = ($bytes['first'] >> 7) & 0x1;
$opcode = $bytes['first'] & 0x0F;
$masked = ($bytes['second'] >> 7) & 0x1;
$length = $bytes['second'] & 0x7F;
// Determine actual payload length
if ($length === 126) {
$ext = $this->read_bytes($socket, 2);
// Ensure we have the correct number of bytes
if (strlen($ext) < 2)
return '';
$length = unpack('n', $ext)[1];
} elseif ($length === 127) {
$ext = $this->read_bytes($socket, 8);
// Ensure we have the correct number of bytes
if (strlen($ext) < 8)
return '';
// unpack 64-bit BE; PHP 7.0+: use J, else fallback
$arr = unpack('J', $ext);
$length = $arr[1];
}
// Read mask key if client→server frame
$maskKey = $masked ? $this->read_bytes($socket, 4) : '';
// Read payload data
$data = $this->read_bytes($socket, $length);
if (empty($data)) {
$this->warning("Received empty frame (ID# $socket)");
return '';
}
// Unmask if needed
if ($masked) {
// Ensure we have the correct number of bytes
if (strlen($maskKey) < 4)
return '';
$unmasked = '';
for ($i = 0; $i < $length; $i++) {
$unmasked .= $data[$i] ^ $maskKey[$i % 4];
}
$data = $unmasked;
}
// Return completed data frame
return $data;
}
private function debug(string $message) {
self::log($message, LOG_DEBUG);
}
private function warning(string $message) {
self::log($message, LOG_WARNING);
}
private function err(string $message) {
self::log($message, LOG_ERR);
}
private function info(string $message) {
self::log($message, LOG_INFO);
}
/**
* Send text frame to client. If the socket connection is not a valid resource, the send
* method will fail silently and return false.
* @param resource $resource The socket or resource id to communicate on.
* @param string|null $payload The string to wrap in a web socket frame to send to the clients
* @return bool
*/
public static function send($resource, ?string $payload): bool {
if (!is_resource($resource)) {
self::log("Cannot send: invalid resource", LOG_ERR);
return false;
}
if ($payload === null) {
@fwrite($resource, "\x88\x00"); // 0x88 = close frame, no payload
return true;
}
$payload_length = strlen($payload);
$frame_header = "\x81"; // FIN = 1, text frame
// Create frame header
if ($payload_length <= 125) {
$frame_header .= chr($payload_length);
} elseif ($payload_length <= 65535) {
$frame_header .= chr(126) . pack('n', $payload_length);
} else {
$frame_header .= chr(127) . pack('J', $payload_length); // PHP 7.1+ supports 'J' for 64-bit unsigned
}
$frame = $frame_header . $payload;
// Attempt to write full frame
$written = @fwrite($resource, $frame);
if ($written === false) {
self::log("fwrite() failed for socket " . (int) $resource, LOG_ERR);
throw new socket_disconnected_exception($resource);
}
if ($written < strlen($frame)) {
self::log("Partial frame sent: {$written}/" . strlen($frame) . " bytes", LOG_WARNING);
return false;
}
return true;
}
/**
* Get the IP and port of the connected remote system.
* @param resource $resource The resource or stream of the connection
* @return array An associative array of remote_ip and remote_port
*/
public static function get_remote_info($resource): array {
[$remote_ip, $remote_port] = explode(':', stream_socket_get_name($resource, true), 2);
return ['remote_ip' => $remote_ip, 'remote_port' => $remote_port];
}
}

View File

@@ -0,0 +1,18 @@
#
# Install with:
#
# cp debian-websockets.service /etc/systemd/system/websockets.service
# systemctl daemon-reload
# systemctl enable --now websockets.service
#
[Unit]
Description=Websocket Router Service
[Service]
ExecStart=/usr/bin/php /var/www/fusionpbx/core/websockets/resources/service/websockets.php --no-fork
Restart=on-failure
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env php
<?php
/*
* FusionPBX
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is FusionPBX
*
* The Initial Developer of the Original Code is
* Mark J Crane <markjcrane@fusionpbx.com>
* Portions created by the Initial Developer are Copyright (C) 2008-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* Mark J Crane <markjcrane@fusionpbx.com>
* Tim Fry <tim@fusionpbx.com>
*/
declare(strict_types=1);
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
die("This script requires PHP 7.1.0 or higher. You are running " . PHP_VERSION . "\n");
}
//
// Only run from the command line
//
if (PHP_SAPI !== 'cli') {
die('This script can only be run from the command line.');
}
//
// Get the framework files
//
require_once dirname(__DIR__, 4) . '/resources/require.php';
try {
//
// Create a web socket service
//
$ws_server = websocket_service::create();
//
// Exit with status code given by run return value
//
exit($ws_server->run());
} catch (Throwable $ex) {
////////////////////////////////////////////////////
// Here we catch all exceptions and log the error //
////////////////////////////////////////////////////
//
// Get the error details
//
$message = $ex->getMessage();
$code = $ex->getCode();
$file = $ex->getFile();
$line = $ex->getLine();
//
// Show user the details
//
echo "FATAL ERROR: '$message' (ERROR CODE: $code) FROM $file (Line: $line)\n";
echo $ex->getTraceAsString() . "\n";
//
// Exit with non-zero status code
//
exit($ex->getCode());
}