fuckwebsockets/lib/fuckwebsockets.php

689 lines
18 KiB
PHP

<?php
class fuckwebsockets{
public const op_continue = 0x00; // 0
public const op_text = 0x01; // 1
public const op_binary = 0x02; // 2
public const op_disconnect = 0x08; // 8
public const op_ping = 0x09; // 9
public const op_pong = 0x0a; // 10
public const close_normal = 1000; // 1000
public const close_going_away = 1001; // 1001
public const close_protocol = 1002; // 1002
public const close_bad_data = 1003; // 1003
public const close_no_status = 1005; // 1005
public const close_abnormal = 1006; // 1006
public const close_bad_payload = 1007; // 1007
public const close_policy = 1008; // 1008
public const close_too_big = 1009; // 1009
public const close_mis_ext = 1010; // 1010
public const close_srv_error = 1011; // 1011
public const close_tls = 1015; // 1015
public function create_server(array $options){
$this->options =
array_merge(
[
"ip" => "localhost", // ip.
"port" => 8080, // port. anything under 1000 requires root
"ip_source" => null, // "X-Forwarded-For" to get IP from that header, null for raw IP
"origin_whitelist" => [], // leave empty to allow any origins. Eg: ["http://localhost"]
"max_header_size" => 1024, // in bytes
"max_header_rcvtime" => 10, // in seconds
"max_tcp_sndtime" => 30, // in seconds
"tcp_write_size" => 4096, // in bytes
"tcp_keepalive" => true,
"ws_ping_interval" => 0, // int for ping interval, 0 for disabled, in seconds
"max_ws_rcv_size" => 4096, // in bytes
"http_headers" => [], // Eg ["Host" => "localhost"]
"debug_log" => true
],
$options
);
if($this->options["ip_source"] !== null){
$this->options["ip_source"] = strtolower($this->options["ip_source"]);
}
unset($options);
// define socket on all processes
$this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if(
// make it so we can reuse the address on forced close
!socket_set_option($this->socket, SOL_SOCKET, SO_REUSEADDR, 1) ||
!socket_set_option($this->socket, SOL_SOCKET, SO_SNDTIMEO, ["sec" => $this->options["max_tcp_sndtime"], "usec" => 0]) ||
!socket_set_option($this->socket, SOL_SOCKET, SO_KEEPALIVE, $this->options === true ? 1 : 0) ||
!@socket_bind($this->socket, $this->options["ip"], $this->options["port"]) ||
!@socket_listen($this->socket, 5)
){
throw new Exception("Could not start websocket server: " . socket_strerror(socket_last_error($this->socket)));
socket_close($this->socket);
die();
}
$pid = pcntl_fork();
if($pid === -1){
throw new Exception("Failed to create incoming connection handler process");
}
elseif($pid){
//
// parent:
// wait for incoming unix signals that correspond to application events
//
while(true){
//echo "a\n";
sleep(1);
}
}else{
//
// child:
// subprocess for handling incoming connections
//
// reap dead children
pcntl_signal(SIGCHLD, SIG_IGN);
while(true){
$this->client = new StdClass();
if(($this->client->socket = @socket_accept($this->socket)) === false){
// socket accept failed
continue;
}
$pid = pcntl_fork();
if($pid === -1){
echo "Warn: failed to create connection handler\n";
continue;
}elseif($pid){
// parent: loop over to socket_accept call
continue;
}else{
//
// connection handler
//
$this->client->request = new StdClass();
$this->client->request->handshake = false;
$this->client->request->headers = [];
if($this->options["ip_source"] === null){
socket_getpeername(
$this->client->socket,
$this->client->ip,
$this->client->port
);
}else{
$this->client->ip = "x.x.x.x";
$this->client->port = -1;
}
$this->log($this->client, "TCP connect");
$buffer = null;
$len = 0;
$time_start = microtime(true) + $this->options["max_header_rcvtime"];
$newline = false;
$firstline = true;
// Parse incoming handshake init
while(true){
$timeout = $time_start - microtime(true);
if($timeout <= 0){
$this->http_disconnect($this->client, 408, "Read timeout");
}
$times = explode(".", $timeout, 2);
if(count($times) === 2){
$usec = (int)substr($times[1], 0, 6);
}else{
$usec = 0;
}
// timeout after configured amount
socket_set_option(
$this->client->socket,
SOL_SOCKET,
SO_RCVTIMEO,
[
"sec" => (int)$times[0],
"usec" => $usec
]
);
// hang till we receive data
$this->socket_recv($this->client, $bytes, 1, MSG_WAITALL);
$len++;
if($len >= $this->options["max_header_size"]){
$this->http_disconnect($this->client, 413, "Headers exceeded configured size");
}
if($bytes == "\r"){
continue;
}
if($bytes == "\n"){
if($newline === true){
// received all headers
break;
}
if($firstline){
$firstline = false;
if(
preg_match(
'/^([A-Z]+) ([^ ]+) HTTP\/([0-9.]+)$/i',
$buffer,
$header
)
=== 0
){
$this->http_disconnect($this->client, 400, "Received malformed HTTP method");
}
$header[1] = strtoupper($header[1]);
if($header[1] !== "GET"){
$this->http_disconnect($this->client, 405, "Received invalid HTTP method " . $header[1]);
}
$this->client->request->path = $header[2];
$this->client->request->http_version = (float)$header[3];
$buffer = null;
continue;
}
$newline = true;
$buffer = explode(":", $buffer, 2);
if(count($buffer) !== 2){
$this->http_disconnect($this->client, 400, "Received invalid header line");
}
$buffer[0] = strtolower(trim($buffer[0]));
$buffer[1] = trim($buffer[1]);
if(
strlen($buffer[0]) === 0 ||
strlen($buffer[1]) === 0
){
$this->http_disconnect($this->client, 400, "Received 0-length header name or value");
}
$this->client->request->headers[$buffer[0]] = $buffer[1];
$buffer = null;
continue;
}
$newline = false;
$buffer .= $bytes;
}
// sanitize headers
if(
!isset($this->client->request->headers["host"]) ||
!isset($this->client->request->headers["connection"]) ||
!$this->check_header_value("upgrade", $this->client->request->headers["connection"]) ||
!isset($this->client->request->headers["upgrade"]) ||
!$this->check_header_value("websocket", $this->client->request->headers["upgrade"]) ||
!isset($this->client->request->headers["sec-websocket-version"]) ||
!$this->check_header_value("13", $this->client->request->headers["sec-websocket-version"]) ||
!isset($this->client->request->headers["sec-websocket-key"]) ||
strlen(base64_decode($this->client->request->headers["sec-websocket-key"])) !== 16
){
$this->http_disconnect(
$this->client,
405,
"Not a WebSocket client"
);
}
// get IP
if($this->options["ip_source"] !== null){
if(!isset($this->client->request->headers[$this->options["ip_source"]])){
$this->http_disconnect(
$this->client,
405,
"Missing " . $this->options["ip_source"] . " header"
);
}
$parts =
explode(
":",
$this->client->request->headers[$this->options["ip_source"]],
2
);
$this->client->ip = $parts[0];
if(count($parts) === 2){
$this->client->port = $parts[1];
}
}
// check origin
if(
count($this->options["origin_whitelist"]) !== 0 &&
(
!isset($this->client->request->headers["origin"]) ||
!in_array($this->client->request->headers["origin"], $this->options["origin_whitelist"])
)
){
$this->http_disconnect(
$this->client,
403,
"Origin not allowed, got " . $this->client->request->headers["origin"]
);
}
// send handshake confirmation
$this->http_respond(
$this->client,
101,
array_merge(
$this->options["http_headers"],
[
"Connection" => "Upgrade",
"Upgrade" => "websocket",
"Sec-WebSocket-Accept" =>
base64_encode(
sha1(
$this->client->request->headers["sec-websocket-key"] .
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",
true
)
)
]
)
);
$this->client->request->handshake = true;
$this->log($this->client, "Handshake done");
// handle incoming messages
$buffer = null;
$bufferlength = 0;
// remove socket timeout
socket_set_option(
$this->client->socket,
SOL_SOCKET,
SO_RCVTIMEO,
[
"sec" => 0,
"usec" => 0
]
);
while(true){
// decode message
$this->socket_recv($this->client, $header, 2, MSG_WAITALL);
$finalmessage = (ord($header[0]) & 0x80) === 128;
$opcode = ord($header[0]) & 0x0F;
$masked = (ord($header[1]) & 0x80) === 128;
$messagelength = ord($header[1]) & 0x7F;
switch($messagelength){
case 126:
$this->socket_recv($this->client, $messagelength, 2, MSG_WAITALL);
$messagelength = unpack("n", $messagelength)[1];
break;
case 127:
$this->socket_recv($this->client, $messagelength, 8, MSG_WAITALL);
$messagelength = unpack("J", $messagelength)[1];
break;
}
if($messagelength > $this->options["max_ws_rcv_size"]){
$this->ws_disconnect($this->client, self::close_too_big, "Frame exceeds " . $this->options["max_ws_rcv_size"] . "b limit");
}
if($masked){
// get mask and message payload
$this->socket_recv($this->client, $frame_data, $messagelength + 4, MSG_WAITALL);
for($i=4; $i<$messagelength; $i++){
$frame_data[$i] = $frame_data[$i] ^ $frame_data[$i % 4];
}
$frame_data = substr($frame_data, 4);
}else{
// get message payload
$this->socket_recv($this->client, $frame_data, $messagelength, MSG_WAITALL);
}
// call user functions
switch($opcode){
case self::op_text:
case self::op_binary:
if($finalmessage === false){
// partial message, come back later
$buffer = $frame_data;
$bufferlength = $messagelength;
break;
}
// final message
$this->log($this->client, "Text frame: {$frame_data}");
break;
case self::op_continue:
$buffer .= $frame_data;
$bufferlength += $messagelength;
if($bufferlength > $this->options["max_ws_rcv_size"]){
$this->ws_disconnect($this->client, self::close_too_big, "Message buffer exceeds " . $this->options["max_ws_rcv_size"] . "b limit");
}
if($finalmessage){
// we received the entire thing, trigger event
$this->log($this->client, "Text frame (buffer): {$buffer}");
$buffer = null;
$bufferlength = 0;
}
break;
case self::op_ping:
if($messagelength > 126){
$this->ws_disconnect($this->client, self::close_too_big, "Ping frame exceeded 128 bytes");
}
$this->log($this->client, "Received ping: " . $frame_data);
$this->ws_send($this->client, self::op_pong, $frame_data);
break;
case self::op_pong:
$this->log($this->client, "Received pong: " . $frame_data);
break;
case self::op_disconnect:
$code = unpack("n", $frame_data)[1];
$frame_data = substr($frame_data, 2);
$this->log($this->client, $code . ": Client disconnected. " . $frame_data);
die();
break;
default:
// unknown frame
$this->ws_disconnect($this->client, self::close_bad_data, "Unsupported opcode");
}
}
}
}
}
}
private function socket_recv(object $client, &$bytes, int $len, int $const){
$state = socket_recv($client->socket, $bytes, $len, $const);
if($state === 0){
$this->log($client, "Remote TCP close");
exit(0);
}elseif($state === false){
$error = socket_last_error();
if($error === 0){
return;
}
if($client->request->handshake === false){
$this->http_disconnect($client, 408, socket_strerror($error));
}else{
$this->ws_disconnect($client, self::close_no_status, socket_strerror($error));
}
}
}
private function socket_write(object $client, string $bytes){
$read_ptr = 0;
$len = strlen($bytes);
while(true){
$sent =
socket_write(
$client->socket,
substr(
$bytes,
$read_ptr,
$this->options["tcp_write_size"]
)
);
if($sent === false){
$this->log("Message send failure at byte " . $read_ptr . " (closed)");
exit(0);
break;
}
$read_ptr += $sent;
if($read_ptr === $len){
break;
}
}
}
public function ws_send(object $client, int $opcode, string $message){
$this->socket_write(
$client,
$this->ws_encode(
$opcode,
$message
)
);
}
public function ws_disconnect(object $client, int $disconnect_opcode, string $message){
$this->ws_send(
$client,
self::op_disconnect,
pack("n", $disconnect_opcode) .
$message
);
socket_close($client->socket);
$this->log($client, $disconnect_opcode . ": " . $message . " (closed)");
exit(0);
}
public function ws_encode(int $opcode, string $message){
// 0x80 = hardcoded end msg bit=true & mask=false (8bit)
// $opcode = 1 to 4 bits
$header = 0x80 | $opcode;
$len = strlen($message);
if($len < 126){
$header = pack("CC", $header, $len);
}elseif($len < 65536){
$header = pack("CCn", $header, 126, $len); // 16bit len
}else{
$header = pack("CCJ", $header, 127, $len); // 64bit len
}
return $header . $message;
}
public function log(object $client, string $message){
if($this->options["debug_log"]){
echo $client->ip . ":" . $client->port . "] " . $message . "\n";
}
}
public function http_disconnect(object $client, int $errcode, string $message){
$this->http_respond(
$client,
$errcode,
array_merge(
$this->options["http_headers"],
[
"Content-Type" => "text/plain",
"Sec-WebSocket-Version" => "13",
"Content-Length" => strlen($message),
"Connection" => "close"
]
),
$message
);
socket_close($client->socket);
$this->log($client, $errcode . ": " . $message . " (closed)");
exit(0);
}
public function http_respond(object $client, int $errcode, array $headers, string $message = null){
switch($errcode){
case 100: $errtext = "Continue"; break;
case 101: $errtext = "Switching Protocols"; break;
case 200: $errtext = "OK"; break;
case 201: $errtext = "Created"; break;
case 202: $errtext = "Accepted"; break;
case 203: $errtext = "Non-Authoritative Information"; break;
case 204: $errtext = "No Content"; break;
case 205: $errtext = "Reset Content"; break;
case 206: $errtext = "Partial Content"; break;
case 300: $errtext = "Multiple Choices"; break;
case 301: $errtext = "Moved Permanently"; break;
case 302: $errtext = "Moved Temporarily"; break;
case 303: $errtext = "See Other"; break;
case 304: $errtext = "Not Modified"; break;
case 305: $errtext = "Use Proxy"; break;
case 400: $errtext = "Bad Request"; break;
case 401: $errtext = "Unauthorized"; break;
case 402: $errtext = "Payment Required"; break;
case 403: $errtext = "Forbidden"; break;
case 404: $errtext = "Not Found"; break;
case 405: $errtext = "Method Not Allowed"; break;
case 406: $errtext = "Not Acceptable"; break;
case 407: $errtext = "Proxy Authentication Required"; break;
case 408: $errtext = "Request Time-out"; break;
case 409: $errtext = "Conflict"; break;
case 410: $errtext = "Gone"; break;
case 411: $errtext = "Length Required"; break;
case 412: $errtext = "Precondition Failed"; break;
case 413: $errtext = "Request Entity Too Large"; break;
case 414: $errtext = "Request-URI Too Large"; break;
case 415: $errtext = "Unsupported Media Type"; break;
case 500: $errtext = "Internal Server Error"; break;
case 501: $errtext = "Not Implemented"; break;
case 502: $errtext = "Bad Gateway"; break;
case 503: $errtext = "Service Unavailable"; break;
case 504: $errtext = "Gateway Time-out"; break;
case 505: $errtext = "HTTP Version not supported"; break;
}
$headers_write = null;
foreach($headers as $name => $value){
$headers_write .= $name . ": " . $value . "\r\n";
}
$this->socket_write(
$client,
"HTTP/1.1 " . $errcode . " " . $errtext . "\r\n" .
$headers_write .
"\r\n" .
$message
);
}
private function check_header_value(string $value, string $header){
$headers = explode(",", $header);
foreach($headers as $part){
if(trim(strtolower($part)) == $value){
return true;
}
}
return false;
}
}