From 8809203953ee10053a3e064699cbf46856336a73 Mon Sep 17 00:00:00 2001 From: lolcat Date: Wed, 17 Jan 2024 22:06:13 -0500 Subject: [PATCH] is this thing listening? --- README.md | 12 + lib/fuckwebsockets.php | 690 +++++++++++++++++++++++++++++++++++++++++ license.txt | 13 + run.php | 49 +++ ws_test.html | 49 +++ 5 files changed, 813 insertions(+) create mode 100644 README.md create mode 100644 lib/fuckwebsockets.php create mode 100644 license.txt create mode 100644 run.php create mode 100644 ws_test.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e67448 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# FuckWebSockets + +Synchronous library to create a fucking websocket server. + +## Roadmap + +[x] Read +[ ] Write + +## License + +WTFPL diff --git a/lib/fuckwebsockets.php b/lib/fuckwebsockets.php new file mode 100644 index 0000000..5639fee --- /dev/null +++ b/lib/fuckwebsockets.php @@ -0,0 +1,690 @@ +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, // 0 for disabled, int for ping interval + "max_ws_rcv_size" => 4096, // in bytes + "http_headers" => [] // Eg ["Host" => "localhost"] + ], + $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; + + // 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); + + $opcode = ord($header[0]) & 0x0F; + $finalmessage = ord($header[0]) & 0x80; + $masked = ord($header[1]) & 0x80; + $messagelength = ord($header[1]) & 0x7F; + + // disconnect if frame isn't masked + if(!$masked){ + + $this->ws_disconnect($this->client, self::close_bad_data, "Received unmasked frame"); + } + + // get extension length + $extensionlength = 0; + + if($messagelength >= 0x7E){ + + $extensionlength = 2; + + if($messagelength == 0x7F){ + + $extensionlength = 8; + } + + $messagelength = 0; + + $this->socket_recv($this->client, $header, $extensionlength, MSG_WAITALL); + + for($i=0; $i<$extensionlength; $i++){ + + $messagelength += ord($header[$i]) << ($extensionlength - $i - 1) * 8; + } + } + + if($messagelength > $this->options["max_ws_rcv_size"]){ + + $this->ws_disconnect($this->client, self::close_too_big, "Frame exceeded " . $this->options["max_ws_rcv_size"] . "b limit"); + } + + // get mask + $this->socket_recv($this->client, $mask, 4, MSG_WAITALL); + + // extract data from frame + $this->socket_recv($this->client, $frame_data, $messagelength, MSG_WAITALL); + + // unmask data + for($i=0; $i 0){ + + // message is partial but client is + // sending more op_text frames? makes no sense! + $this->ws_disconnect($this->client, self::close_protocol, "Did not send continue frame for partial message"); + } + + $buffer = $frame_data; + break; + } + + // final message + $this->log($this->client, "Text frame: {$frame_data}"); + break; + + + case self::op_continue: + $buffer .= $frame_data; + + if(strlen($buffer) > $this->options["max_ws_rcv_size"]){ + + $this->ws_disconnect($this->client, self::close_too_big, "Message exceeded " . $this->options["max_ws_rcv_size"] . " bytes limit"); + } + + if($finalmessage === true){ + + // we received the entire thing, trigger event + $this->log($this->client, "Text frame (buffered): {$buffer}"); + $buffer = null; + } + break; + + + case self::op_ping: + if(strlen($frame_data) > 128){ + + $this->ws_disconnect($this->client, self::close_too_big, "Ping frame exceeded 128 bytes"); + } + + $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){ + + if($client->request->handshake === false){ + + $this->http_disconnect($client, 408, "Read timeout"); + }else{ + + $this->ws_disconnect($client, self::close_no_status, "Read timeout"); + } + } + } + + 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){ + + 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; + } +} diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..4af7fe1 --- /dev/null +++ b/license.txt @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2024 lolcat + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/run.php b/run.php new file mode 100644 index 0000000..d237f9c --- /dev/null +++ b/run.php @@ -0,0 +1,49 @@ +ws = new fuckwebsockets(); + + $this->ws->create_server( + [ + "ip" => "localhost", + "port" => 8080 + ] + ); + } + + public function onConnect($client){ + + + } + + public function onFailedConnect($source, $opcode, $reason){ + + + } + + public function onPing($client, $message){ + + + } + + public function onPong($client, $message){ + + + } + + public function onMessage($client, $opcode, $message){ + + + } + + public function onDisconnect($client, $opcode, $reason){ + + + } +} diff --git a/ws_test.html b/ws_test.html new file mode 100644 index 0000000..be1c10d --- /dev/null +++ b/ws_test.html @@ -0,0 +1,49 @@ + + + + + + +

Messages:

+
+ + + +