Merge branch 'main' into wolfssl-testing

This commit is contained in:
ading2210 2024-01-17 01:09:34 -05:00
commit 01622283f5
17 changed files with 221 additions and 178 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
/client/build /client/build
/client/out /client/out
/client/fragments/tmp
/server/.venv /server/.venv
/server/websockify /server/websockify

6
.gitmodules vendored Normal file
View file

@ -0,0 +1,6 @@
[submodule "client/wisp_client"]
path = client/wisp_client
url = https://github.com/MercuryWorkshop/wisp-client-js
[submodule "server/wisp_server"]
path = server/wisp_server
url = https://github.com/MercuryWorkshop/wisp-server-python

View file

@ -10,7 +10,7 @@ This is an experimental port of [libcurl](https://curl.se/libcurl/) to WebAssemb
## Building: ## Building:
You can build this project by running the following commands: You can build this project by running the following commands:
``` ```
git clone https://github.com/ading2210/libcurl.js git clone https://github.com/ading2210/libcurl.js --recursive
cd libcurl.js/client cd libcurl.js/client
./build.sh ./build.sh
``` ```
@ -48,16 +48,16 @@ libcurl.set_websocket("ws://localhost:6001/");
``` ```
## Proxy Server: ## Proxy Server:
The proxy server consists of a [SOCKS5 proxy server](https://github.com/Amaindex/asyncio-socks-server) behind a [websocket TCP proxy](https://github.com/novnc/websockify). The proxy server consists of a standard [Wisp](https://github.com/MercuryWorkshop/wisp-protocol) server, allowing multiple TCP connections to share the same websocket.
To host the proxy server, run the following commands: To host the proxy server, run the following commands:
``` ```
git clone https://github.com/ading2210/libcurl.js git clone https://github.com/ading2210/libcurl.js --recursive
cd libcurl.js/server cd libcurl.js/server
./run.sh ./run.sh
``` ```
You can use the `PORT` and `SOCKS5_PORT` environment variables to control which ports the websocket proxy and the SOCKS5 server run on. You can use the `HOST` and `PORT` environment variables to control the hostname and port that the proxy server listens on.
## Copyright: ## Copyright:
This project is licensed under the GNU AGPL v3. This project is licensed under the GNU AGPL v3.

View file

@ -7,15 +7,20 @@ LIB_DIR="build/curl-wasm/lib/"
OUT_FILE="out/libcurl.js" OUT_FILE="out/libcurl.js"
ES6_FILE="out/libcurl_module.mjs" ES6_FILE="out/libcurl_module.mjs"
MODULE_FILE="out/emscripten_compiled.js" MODULE_FILE="out/emscripten_compiled.js"
FRAGMENTS_DIR="fragments"
WRAPPER_SOURCE="main.js" WRAPPER_SOURCE="main.js"
WISP_CLIENT="wisp_client"
EXPORTED_FUNCS="_load_certs,_perform_request" EXPORTED_FUNCS="_init_curl,_start_request,_tick_request,_active_requests"
RUNTIME_METHODS="addFunction,removeFunction,allocate,ALLOC_NORMAL" RUNTIME_METHODS="addFunction,removeFunction,allocate,ALLOC_NORMAL"
COMPILER_OPTIONS="-o $MODULE_FILE -lcurl -lwolfssl -lcjson -lz -lbrotlidec -lbrotlicommon -I $INCLUDE_DIR -L $LIB_DIR" COMPILER_OPTIONS="-o $MODULE_FILE -lcurl -lwolfssl -lcjson -lz -lbrotlidec -lbrotlicommon -I $INCLUDE_DIR -L $LIB_DIR"
EMSCRIPTEN_OPTIONS="-lwebsocket.js -sSINGLE_FILE -sASYNCIFY -sALLOW_TABLE_GROWTH -sEXPORTED_FUNCTIONS=$EXPORTED_FUNCS -sEXPORTED_RUNTIME_METHODS=$RUNTIME_METHODS" EMSCRIPTEN_OPTIONS="-lwebsocket.js -sASSERTIONS=1 -sALLOW_TABLE_GROWTH -sALLOW_MEMORY_GROWTH -sEXPORTED_FUNCTIONS=$EXPORTED_FUNCS -sEXPORTED_RUNTIME_METHODS=$RUNTIME_METHODS"
if [ "$1" = "release" ]; then if [ "$1" = "release" ]; then
COMPILER_OPTIONS="-O3 $COMPILER_OPTIONS" COMPILER_OPTIONS="-Oz -flto $COMPILER_OPTIONS"
EMSCRIPTEN_OPTIONS="-sSINGLE_FILE $EMSCRIPTEN_OPTIONS"
else
COMPILER_OPTIONS="$COMPILER_OPTIONS --profiling"
fi fi
#ensure deps are compiled #ensure deps are compiled
@ -31,15 +36,19 @@ COMPILE_CMD="emcc main.c $COMPILER_OPTIONS $EMSCRIPTEN_OPTIONS"
echo $COMPILE_CMD echo $COMPILE_CMD
$COMPILE_CMD $COMPILE_CMD
#patch the output to work around some emscripten bugs
sed -i 's/err("__syscall_getsockname " \?+ \?fd);//' $MODULE_FILE
sed -i 's/function _emscripten_console_error(str) {/& if(UTF8ToString(str).endsWith("__syscall_setsockopt\\n")) return;/' $MODULE_FILE
#merge compiled emscripten module and wrapper code #merge compiled emscripten module and wrapper code
cp $WRAPPER_SOURCE $OUT_FILE cp $WRAPPER_SOURCE $OUT_FILE
sed -i "/__emscripten_output__/r $MODULE_FILE" $OUT_FILE sed -i "/__emscripten_output__/r $MODULE_FILE" $OUT_FILE
rm $MODULE_FILE rm $MODULE_FILE
#add wisp libraries
sed -i "/__extra_libraries__/r $WISP_CLIENT/polyfill.js" $OUT_FILE
sed -i "/__extra_libraries__/r $WISP_CLIENT/wisp.js" $OUT_FILE
sed -i "/__extra_libraries__/r ./messages.js" $OUT_FILE
#apply patches
python3 patcher.py $FRAGMENTS_DIR $OUT_FILE
#generate es6 module #generate es6 module
cp $OUT_FILE $ES6_FILE cp $OUT_FILE $ES6_FILE
sed -i 's/window.libcurl/export const libcurl/' $ES6_FILE sed -i 's/window.libcurl/export const libcurl/' $ES6_FILE

View file

@ -0,0 +1,5 @@
/* INSERT
var ?opts ?= ?undefined;
*/
var parts=addr.split("/");
url = url + parts[0] + ":" + port;

View file

@ -0,0 +1,9 @@
/* DELETE
err\("__syscall_getsockname " ?\+ ?fd\);
*/
/* INSERT
function _emscripten_console_error\(str\) {
*/
if (UTF8ToString(str).endsWith("__syscall_setsockopt\\n")) return;

View file

@ -0,0 +1,4 @@
/* REPLACE
new WebSocketConstructor
*/
new WispWebSocket

View file

@ -8,12 +8,28 @@
#include "curl/header.h" #include "curl/header.h"
#include "cjson/cJSON.h" #include "cjson/cJSON.h"
#include "cacert.h" #include "cacert.h"
#include "curl/multi.h"
typedef void(*DataCallback)(char* chunk_ptr, int chunk_size); typedef void(*DataCallback)(char* chunk_ptr, int chunk_size);
typedef void(*EndCallback)(int error, char* response_json); typedef void(*EndCallback)(int error, char* response_json);
void finish_request(CURLMsg *curl_msg);
#define ERROR_REDIRECT_DISALLOWED -1 #define ERROR_REDIRECT_DISALLOWED -1
CURLM *multi_handle;
int request_active = 0;
struct RequestInfo {
int abort_on_redirect;
struct CURLMsg *curl_msg;
struct curl_slist* headers_list;
EndCallback end_callback;
};
int starts_with(const char *a, const char *b) {
return strncmp(a, b, strlen(b)) == 0;
}
int write_function(void *data, size_t size, size_t nmemb, DataCallback data_callback) { int write_function(void *data, size_t size, size_t nmemb, DataCallback data_callback) {
long real_size = size * nmemb; long real_size = size * nmemb;
char* chunk = malloc(real_size); char* chunk = malloc(real_size);
@ -23,24 +39,31 @@ int write_function(void *data, size_t size, size_t nmemb, DataCallback data_call
return real_size; return real_size;
} }
void perform_request(const char* url, const char* json_params, DataCallback data_callback, EndCallback end_callback, const char* body, int body_length) { int active_requests() {
printf("downloading %s\n", url); return request_active;
}
CURL *http_handle; void tick_request() {
CURLM *multi_handle; CURLMcode mc;
int still_running = 1; struct CURLMsg *curl_msg;
request_active = 1;
mc = curl_multi_perform(multi_handle, &request_active);
int msgq = 0;
curl_msg = curl_multi_info_read(multi_handle, &msgq);
if (curl_msg && curl_msg->msg == CURLMSG_DONE) {
finish_request(curl_msg);
}
}
void start_request(const char* url, const char* json_params, DataCallback data_callback, EndCallback end_callback, const char* body, int body_length) {
CURL *http_handle = curl_easy_init();
int abort_on_redirect = 0; int abort_on_redirect = 0;
char error_buffer[CURL_ERROR_SIZE];
curl_global_init(CURL_GLOBAL_DEFAULT);
http_handle = curl_easy_init();
curl_easy_setopt(http_handle, CURLOPT_URL, url); curl_easy_setopt(http_handle, CURLOPT_URL, url);
curl_easy_setopt(http_handle, CURLOPT_PROXY, "socks5h://127.0.0.1:1234");
curl_easy_setopt(http_handle, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5);
curl_easy_setopt(http_handle, CURLOPT_CAINFO, "/cacert.pem"); curl_easy_setopt(http_handle, CURLOPT_CAINFO, "/cacert.pem");
curl_easy_setopt(http_handle, CURLOPT_CAPATH, "/cacert.pem"); curl_easy_setopt(http_handle, CURLOPT_CAPATH, "/cacert.pem");
curl_easy_setopt(http_handle, CURLOPT_ERRORBUFFER, error_buffer);
//callbacks to pass the response data back to js //callbacks to pass the response data back to js
curl_easy_setopt(http_handle, CURLOPT_WRITEFUNCTION, &write_function); curl_easy_setopt(http_handle, CURLOPT_WRITEFUNCTION, &write_function);
@ -50,6 +73,11 @@ void perform_request(const char* url, const char* json_params, DataCallback data
curl_easy_setopt(http_handle, CURLOPT_FOLLOWLOCATION, 1); curl_easy_setopt(http_handle, CURLOPT_FOLLOWLOCATION, 1);
curl_easy_setopt(http_handle, CURLOPT_ACCEPT_ENCODING, ""); curl_easy_setopt(http_handle, CURLOPT_ACCEPT_ENCODING, "");
//if url is a websocket, tell curl that we should handle the connection manually
if (starts_with(url, "wss://") || starts_with(url, "ws://")) {
curl_easy_setopt(http_handle, CURLOPT_CONNECT_ONLY, 2L);
}
//parse json options //parse json options
cJSON* request_json = cJSON_Parse(json_params); cJSON* request_json = cJSON_Parse(json_params);
cJSON* item = NULL; cJSON* item = NULL;
@ -100,47 +128,34 @@ void perform_request(const char* url, const char* json_params, DataCallback data
curl_easy_setopt(http_handle, CURLOPT_POSTFIELDS, body); curl_easy_setopt(http_handle, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(http_handle, CURLOPT_POSTFIELDSIZE, body_length); curl_easy_setopt(http_handle, CURLOPT_POSTFIELDSIZE, body_length);
} }
struct RequestInfo *request_info = malloc(sizeof(struct RequestInfo));
request_info->abort_on_redirect = abort_on_redirect;
request_info->curl_msg = NULL;
request_info->headers_list = headers_list;
request_info->end_callback = end_callback;
curl_easy_setopt(http_handle, CURLOPT_PRIVATE, request_info);
multi_handle = curl_multi_init();
curl_multi_add_handle(multi_handle, http_handle); curl_multi_add_handle(multi_handle, http_handle);
}
CURLMcode mc;
struct CURLMsg *m;
error_buffer[0] = 0;
do { void finish_request(CURLMsg *curl_msg) {
mc = curl_multi_perform(multi_handle, &still_running); //get initial request info from the http handle
struct RequestInfo *request_info;
if(!mc) CURL *http_handle = curl_msg->easy_handle;
mc = curl_multi_poll(multi_handle, NULL, 0, 1000, NULL); curl_easy_getinfo(http_handle, CURLINFO_PRIVATE, &request_info);
if(mc) {
fprintf(stderr, "curl_multi_poll() failed, code %d.\n", (int)mc);
break;
}
int msgq = 0; int error = (int) curl_msg->data.result;
m = curl_multi_info_read(multi_handle, &msgq);
//ensure we dont block the main thread
emscripten_sleep(0);
} while(still_running);
int error = (int) m->data.result;
long response_code; long response_code;
curl_easy_getinfo(http_handle, CURLINFO_RESPONSE_CODE, &response_code); curl_easy_getinfo(http_handle, CURLINFO_RESPONSE_CODE, &response_code);
if (abort_on_redirect && response_code / 100 == 3) { if (request_info->abort_on_redirect && response_code / 100 == 3) {
error = ERROR_REDIRECT_DISALLOWED; error = ERROR_REDIRECT_DISALLOWED;
} }
//create new json object with response info //create new json object with response info
cJSON* response_json = cJSON_CreateObject(); cJSON* response_json = cJSON_CreateObject();
cJSON* error_item = cJSON_CreateString(error_buffer);
cJSON_AddItemToObject(response_json, "error", error_item);
cJSON* status_item = cJSON_CreateNumber(response_code); cJSON* status_item = cJSON_CreateNumber(response_code);
cJSON_AddItemToObject(response_json, "status", status_item); cJSON_AddItemToObject(response_json, "status", status_item);
@ -168,13 +183,11 @@ void perform_request(const char* url, const char* json_params, DataCallback data
cJSON_Delete(response_json); cJSON_Delete(response_json);
//clean up curl //clean up curl
curl_slist_free_all(headers_list); curl_slist_free_all(request_info->headers_list);
curl_multi_remove_handle(multi_handle, http_handle); curl_multi_remove_handle(multi_handle, http_handle);
curl_easy_cleanup(http_handle); curl_easy_cleanup(http_handle);
curl_multi_cleanup(multi_handle); (*request_info->end_callback)(error, response_json_str);
curl_global_cleanup(); free(request_info);
(*end_callback)(error, response_json_str);
} }
char* copy_bytes(const char* ptr, const int size) { char* copy_bytes(const char* ptr, const int size) {
@ -183,7 +196,10 @@ char* copy_bytes(const char* ptr, const int size) {
return new_ptr; return new_ptr;
} }
void load_certs() { void init_curl() {
curl_global_init(CURL_GLOBAL_DEFAULT);
multi_handle = curl_multi_init();
FILE *file = fopen("/cacert.pem", "wb"); FILE *file = fopen("/cacert.pem", "wb");
fwrite(_cacert_pem, 1, _cacert_pem_len, file); fwrite(_cacert_pem, 1, _cacert_pem_len, file);
fclose(file); fclose(file);

View file

@ -4,73 +4,11 @@ window.libcurl = (function() {
//emscripten compiled code is inserted here //emscripten compiled code is inserted here
/* __emscripten_output__ */ /* __emscripten_output__ */
const websocket_url = `wss://${location.hostname}/ws`; //extra client code goes here
/* __extra_libraries__ */
const status_messages = { const websocket_url = `wss://${location.hostname}/ws/`;
100: "Continue", var event_loop = null;
101: "Switching Protocols",
102: "Processing",
103: "Early Hints",
200: "OK",
201: "Created",
202: "Accepted",
203: "Non-Authoritative Information",
204: "No Content",
205: "Reset Content",
206: "Partial Content",
207: "Multi-Status",
208: "Already Reported",
226: "IM Used",
300: "Multiple Choices",
301: "Moved Permanently",
302: "Found",
303: "See Other",
304: "Not Modified",
305: "Use Proxy",
306: "Switch Proxy",
307: "Temporary Redirect",
308: "Permanent Redirect",
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
406: "Not Acceptable",
407: "Proxy Authentication Required",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
411: "Length Required",
412: "Precondition Failed",
413: "Payload Too Large",
414: "URI Too Long",
415: "Unsupported Media Type",
416: "Range Not Satisfiable",
417: "Expectation Failed",
418: "I'm a teapot",
421: "Misdirected Request",
422: "Unprocessable Content",
423: "Locked",
424: "Failed Dependency",
425: "Too Early",
426: "Upgrade Required",
428: "Precondition Required",
429: "Too Many Requests",
431: "Request Header Fields Too Large",
451: "Unavailable For Legal Reasons",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
505: "HTTP Version Not Supported",
506: "Variant Also Negotiates",
507: "Insufficient Storage",
508: "Loop Detected",
510: "Not Extended",
511: "Network Authentication Required"
}
//a case insensitive dictionary for request headers //a case insensitive dictionary for request headers
class Headers { class Headers {
@ -148,8 +86,19 @@ function perform_request(url, params, js_data_callback, js_end_callback, body=nu
end_callback_ptr = Module.addFunction(end_callback, "vii"); end_callback_ptr = Module.addFunction(end_callback, "vii");
data_callback_ptr = Module.addFunction(data_callback, "vii"); data_callback_ptr = Module.addFunction(data_callback, "vii");
_perform_request(url_ptr, params_ptr, data_callback_ptr, end_callback_ptr, body_ptr, body_length); _start_request(url_ptr, params_ptr, data_callback_ptr, end_callback_ptr, body_ptr, body_length);
_free(params_ptr); _free(params_ptr);
_tick_request();
if (!event_loop) {
event_loop = setInterval(() => {
_tick_request();
if (!_active_requests()) {
clearInterval(event_loop);
event_loop = null;
}
}, 0);
}
} }
function merge_arrays(arrays) { function merge_arrays(arrays) {
@ -231,7 +180,7 @@ function set_websocket_url(url) {
function main() { function main() {
console.log("emscripten module loaded"); console.log("emscripten module loaded");
_load_certs(); _init_curl();
set_websocket_url(websocket_url); set_websocket_url(websocket_url);
let load_event = new Event("libcurl_load"); let load_event = new Event("libcurl_load");

65
client/messages.js Normal file
View file

@ -0,0 +1,65 @@
const status_messages = {
100: "Continue",
101: "Switching Protocols",
102: "Processing",
103: "Early Hints",
200: "OK",
201: "Created",
202: "Accepted",
203: "Non-Authoritative Information",
204: "No Content",
205: "Reset Content",
206: "Partial Content",
207: "Multi-Status",
208: "Already Reported",
226: "IM Used",
300: "Multiple Choices",
301: "Moved Permanently",
302: "Found",
303: "See Other",
304: "Not Modified",
305: "Use Proxy",
306: "Switch Proxy",
307: "Temporary Redirect",
308: "Permanent Redirect",
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
406: "Not Acceptable",
407: "Proxy Authentication Required",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
411: "Length Required",
412: "Precondition Failed",
413: "Payload Too Large",
414: "URI Too Long",
415: "Unsupported Media Type",
416: "Range Not Satisfiable",
417: "Expectation Failed",
418: "I'm a teapot",
421: "Misdirected Request",
422: "Unprocessable Content",
423: "Locked",
424: "Failed Dependency",
425: "Too Early",
426: "Upgrade Required",
428: "Precondition Required",
429: "Too Many Requests",
431: "Request Header Fields Too Large",
451: "Unavailable For Legal Reasons",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
505: "HTTP Version Not Supported",
506: "Variant Also Negotiates",
507: "Insufficient Storage",
508: "Loop Detected",
510: "Not Extended",
511: "Network Authentication Required"
}

26
client/patcher.py Normal file
View file

@ -0,0 +1,26 @@
import re
import sys
import pathlib
match_regex = r'/\* (.+?)\n(.+?)\n\*/\n(.*?)(\n\n|$)'
fragments_dir = sys.argv[1]
target_dir = sys.argv[2]
fragments_path = pathlib.Path(fragments_dir)
target_path = pathlib.Path(target_dir)
target_text = target_path.read_text()
for fragment_file in fragments_path.iterdir():
print(f"applying patch from {fragment_file.name}")
fragment_text = fragment_file.read_text()
matches = re.findall(match_regex, fragment_text, re.S)
for mode, patch_regex, patch_text, _ in matches:
if mode == "DELETE":
target_text = re.sub(patch_regex, "", target_text)
elif mode == "REPLACE":
target_text = re.sub(patch_regex, patch_text, target_text)
elif mode == "INSERT":
target_text = re.sub("("+patch_regex+")", r'\1'+patch_text, target_text)
target_path.write_text(target_text)

View file

@ -17,7 +17,7 @@ git clone -b master --depth=1 https://github.com/curl/curl
cd curl cd curl
autoreconf -fi autoreconf -fi
emconfigure ./configure --host i686-linux --disable-shared --disable-threaded-resolver --without-libpsl --disable-netrc --disable-ipv6 --disable-tftp --disable-ntlm-wb --with-wolfssl=$WOLFSSL_PREFIX --with-zlib=$ZLIB_PREFIX --with-brotli=$BROTLI_PREFIX emconfigure ./configure --host i686-linux --disable-shared --disable-threaded-resolver --without-libpsl --disable-netrc --disable-ipv6 --disable-tftp --disable-ntlm-wb --enable-websockets --with-wolfssl=$WOLFSSL_PREFIX --with-zlib=$ZLIB_PREFIX --with-brotli=$BROTLI_PREFIX
emmake make -j$CORE_COUNT CFLAGS="-Os -pthread" LIBS="-lbrotlicommon" emmake make -j$CORE_COUNT CFLAGS="-Os -pthread" LIBS="-lbrotlicommon"
rm -rf $PREFIX rm -rf $PREFIX

1
client/wisp_client Submodule

@ -0,0 +1 @@
Subproject commit 0a80885090b6247f42bc07cc85b441d8d719f551

View file

@ -1,45 +0,0 @@
import logging
import os
from asyncio_socks_server.app import SocksServer
from websockify.websocketproxy import WebSocketProxy
#start a socks5 proxy as well as websockify
def setup_logging(prefix):
stderr_handler = logging.StreamHandler()
stderr_handler.setLevel(logging.DEBUG)
log_formatter = logging.Formatter(prefix + "%(message)s")
stderr_handler.setFormatter(log_formatter)
root = logging.getLogger()
root.addHandler(stderr_handler)
root.setLevel(logging.INFO)
def start_websockify(listen_port, proxy_port):
options = {
"listen_host": "127.0.0.1",
"listen_port": int(listen_port),
"target_host": "127.0.0.1",
"target_port": int(proxy_port)
}
server = WebSocketProxy(**options)
server.start_server()
def start_socks(proxy_port):
socks_app = SocksServer(
LISTEN_HOST="127.0.0.1",
LISTEN_PORT=int(proxy_port)
)
socks_app.run()
if __name__ == "__main__":
listen_port = os.environ.get("PORT") or 6001
proxy_port = os.environ.get("SOCKS5_PORT") or 6002
pid = os.fork()
if pid == 0:
setup_logging("[websockify] ")
start_websockify(listen_port, proxy_port)
else:
start_socks(proxy_port)

View file

@ -1,2 +0,0 @@
websockify
asyncio-socks-server

View file

@ -4,16 +4,14 @@
set -e set -e
cd wisp_server
if [ ! -d ".venv" ]; then if [ ! -d ".venv" ]; then
python3 -m venv .venv python3 -m venv .venv
fi fi
source .venv/bin/activate source .venv/bin/activate
if ! python3 -c "import asyncio_socks_server, websockify" 2> /dev/null; then if ! python3 -c "import websockets" 2> /dev/null; then
pip3 install asyncio-socks-server pip3 install -r requirements.txt
git clone https://github.com/novnc/websockify -b master --depth=1
pip3 install ./websockify
rm -rf websockify
fi fi
python3 main.py python3 main.py

1
server/wisp_server Submodule

@ -0,0 +1 @@
Subproject commit 874734623e4dfc4652b34a1bc61e1e35ca86dee8