is this thing listening?

This commit is contained in:
lolcat 2024-01-17 22:06:13 -05:00
commit 8809203953
5 changed files with 813 additions and 0 deletions

12
README.md Normal file
View File

@ -0,0 +1,12 @@
# FuckWebSockets
Synchronous library to create a fucking websocket server.
## Roadmap
[x] Read
[ ] Write
## License
WTFPL

690
lib/fuckwebsockets.php Normal file
View File

@ -0,0 +1,690 @@
<?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, // 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<strlen($frame_data); $i++){
$frame_data[$i] = $frame_data[$i] ^ $mask[$i % 4];
}
// handle data
switch($opcode){
case self::op_text:
case self::op_binary:
if($finalmessage === false){
// partial message, come back later
if(strlen($buffer) > 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;
}
}

13
license.txt Normal file
View File

@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2024 lolcat <will@lolcat.ca>
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.

49
run.php Normal file
View File

@ -0,0 +1,49 @@
<?php
new app();
class app{
public function __construct(){
include "lib/fuckwebsockets.php";
$this->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){
}
}

49
ws_test.html Normal file
View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<style>
body{
background:#1d2021;
color:#bdae93;
}
.page{
height:500px;
width:400px;
overflow-y:scroll;
border:1px solid #a89984;
}
</style>
</head>
<body>
<h1>Messages:</h1>
<div class="page"></div>
<input type="text" placeholder="Enter message" id="area" autofocus>
<script>
var page = document.getElementsByClassName("page")[0]
var host = 'ws://localhost:8080/';
var socket = new WebSocket("ws://localhost:8080");
socket.onmessage = function(e){
page.innerHTML += "<br>" + e.data;
};
socket.onerror = function(e){
page.innerHTML += "<h3>Connection error</h3>";
}
var area = document.getElementById("area");
area.onkeyup = function(e){
if(e.key == "Enter"){
socket.send(area.value);
area.value = "";
area.focus();
}
}
</script>
</body>
</html>