From 519fdbe6c8459eef86fcc674918da99cace4382d Mon Sep 17 00:00:00 2001 From: sdomi Date: Wed, 26 Feb 2025 01:22:23 +0100 Subject: [PATCH 1/2] ws: full hecking rewrite --- .resources/primary_config.sh | 1 + src/response/101.sh | 42 ++++++------ src/server.sh | 82 +++++++++++++---------- src/ws.sh | 123 ++++++++++++++++++----------------- 4 files changed, 129 insertions(+), 119 deletions(-) diff --git a/.resources/primary_config.sh b/.resources/primary_config.sh index dfc96e3..9f0d1d9 100644 --- a/.resources/primary_config.sh +++ b/.resources/primary_config.sh @@ -21,6 +21,7 @@ cfg[ssl_cert]='' cfg[ssl_key]='' cfg[extension]='shs' +cfg[extension_websocket]='shx' #cfg[encoding]='UTF-8' # UTF-8 by default, used by iconv cfg[extra_headers]="server: HTTP.sh/$HTTPSH_VERSION (devel)" diff --git a/src/response/101.sh b/src/response/101.sh index 72cf7e1..6d11ec6 100755 --- a/src/response/101.sh +++ b/src/response/101.sh @@ -1,31 +1,29 @@ +#!/usr/bin/env bash +[[ ! -f src/libsh/bin/bin.sh ]] && return # no websocket for you! clone the repo +source src/libsh/bin/bin.sh + +if [[ "${r[status]}" == 101 && "${r[url_clean]}" != *\.${cfg[extension_websocket]} ]] || + [[ "${r[status]}" == 102 && "${r[view]}" != *\.${cfg[extension_websocket]} ]]; then + echo "Rejecting an invalid WebSocket connection." >&2 + declare -p r >&2 + return +fi + echo "HTTP/1.1 101 Web Socket Protocol Handshake Connection: Upgrade Upgrade: WebSocket ${cfg[extra_headers]}" if [[ ${r[websocket_key]} != '' ]]; then - accept=$(echo -ne $(echo "${r[websocket_key]}""258EAFA5-E914-47DA-95CA-C5AB0DC85B11" | sha1sum | sed 's/ //g;s/-//g;s/.\{2\}/\\x&/g') | base64) - echo "Sec-WebSocket-Accept: "$accept + accept=$(echo -ne $(echo -n "${r[websocket_key]}""258EAFA5-E914-47DA-95CA-C5AB0DC85B11" | sha1sum | sed 's/ //g;s/-//g;s/.\{2\}/\\x&/g') | base64) + echo -n "Sec-WebSocket-Accept: $accept" fi -echo -e "\r\n\r\n" - -#echo "Laura is cute <3" -#WebSocket-Location: ws://localhost:1337/ -#WebSocket-Origin: http://localhost:1337/\r\n\r\n " +echo -ne "\r\n\r\n" source ./src/ws.sh +unset IFS -#input='' -#while read -N 1 chr; do -# input=$input$chr -# if [[ $chr == "\r" ]]; then -# break -# fi -#done - - -exit 0 -#while true; do -# read test -# echo $test -# sleep 1 -#done +if [[ "${r[status]}" == 101 ]]; then + source "${r[uri]}" +elif [[ "${r[status]}" == 102 ]]; then + source "${r[view]}" +fi diff --git a/src/server.sh b/src/server.sh index b5ce2cb..5781bea 100755 --- a/src/server.sh +++ b/src/server.sh @@ -167,42 +167,44 @@ echo "$(date) - IP: ${r[ip]}, PROTO: ${r[proto]}, URL: ${r[url]}, GET_data: ${ge [[ -f "${cfg[namespace]}/routes.sh" ]] && source "${cfg[namespace]}/routes.sh" -if [[ ${r[status]} != 101 ]]; then - for (( i=0; i<${#route[@]}; i=i+3 )); do - if [[ "$(grep -Poh "^${route[$((i+1))]}$" <<< "${r[url_clean]}")" != "" ]] || [[ "$(grep -Poh "^${route[$((i+1))]}$" <<< "${r[url_clean]}/")" != "" ]]; then - r[status]=212 - r[view]="${route[$((i+2))]}" - IFS='/' - url=(${route[$i]}) - url_=(${r[url_clean]}) - unset IFS - for (( j=0; j<${#url[@]}; j++ )); do - # TODO: think about the significance of this if really hard when i'm less tired - if [[ ${url_[$j]} != '' && ${url[$j]} == ":"* ]]; then - params[${url[$j]/:/}]="${url_[$j]}" - fi - done - break - fi - done - unset IFS - if [[ ${r[status]} != 212 ]]; then - if [[ -a "${r[uri]}" && ! -r "${r[uri]}" ]]; then - r[status]=403 - elif [[ "${r[uri]}" != "$(realpath "${cfg[namespace]}/${cfg[root]}")"* ]]; then - r[status]=403 - elif [[ -f "${r[uri]}" ]]; then - r[status]=200 - elif [[ -d "${r[uri]}" ]]; then - for name in ${cfg[index]}; do - if [[ -f "${r[uri]}/$name" ]]; then - r[uri]="${r[uri]}/$name" - r[status]=200 - fi - done +for (( i=0; i<${#route[@]}; i=i+3 )); do + if [[ "$(grep -Poh "^${route[$((i+1))]}$" <<< "${r[url_clean]}")" != "" ]] || [[ "$(grep -Poh "^${route[$((i+1))]}$" <<< "${r[url_clean]}/")" != "" ]]; then + if [[ "${r[status]}" == 101 ]]; then + r[status]=102 # internal, temp else - r[status]=404 + r[status]=212 fi + r[view]="${route[$((i+2))]}" + IFS='/' + url=(${route[$i]}) + url_=(${r[url_clean]}) + unset IFS + for (( j=0; j<${#url[@]}; j++ )); do + # TODO: think about the significance of this if really hard when i'm less tired + if [[ ${url_[$j]} != '' && ${url[$j]} == ":"* ]]; then + params[${url[$j]/:/}]="${url_[$j]}" + fi + done + break + fi +done +unset IFS +if [[ ${r[status]} != 212 && ${r[status]} != 102 ]]; then + if [[ -a "${r[uri]}" && ! -r "${r[uri]}" ]]; then + r[status]=403 + elif [[ "${r[uri]}" != "$(realpath "${cfg[namespace]}/${cfg[root]}")"* ]]; then + r[status]=403 + elif [[ -f "${r[uri]}" ]]; then + [[ "${r[status]}" != 101 ]] && r[status]=200 + elif [[ -d "${r[uri]}" ]]; then + for name in ${cfg[index]}; do + if [[ -f "${r[uri]}/$name" ]]; then + r[uri]="${r[uri]}/$name" + r[status]=200 + fi + done + else + r[status]=404 fi fi @@ -266,12 +268,20 @@ fi if [[ ${r[status]} == 210 && ${cfg[autoindex]} == true ]]; then source "src/response/listing.sh" elif [[ ${r[status]} == 200 || ${r[status]} == 212 ]]; then - source "src/response/200.sh" + # ban the WS executable extensions + # default defined here for legacy compat only + if [[ "${r[view]}" == *".${cfg[extension_websocket]-shx}" || + "${r[url_clean]}" == *".${cfg[extension_websocket]-shx}" ]]; then + source "src/response/403.sh" # TODO: should be 400/500, ig + else + source "src/response/200.sh" + fi elif [[ ${r[status]} == 401 ]]; then source "src/response/401.sh" elif [[ ${r[status]} == 404 ]]; then source "src/response/404.sh" -elif [[ ${r[status]} == 101 && ${cfg[websocket_enable]} == true ]]; then +elif [[ ${r[status]} == 101 || ${r[status]} == 102 ]] && + [[ ${cfg[websocket_enable]} == true ]]; then source "src/response/101.sh" else source "src/response/403.sh" diff --git a/src/ws.sh b/src/ws.sh index bd08867..8e9020a 100755 --- a/src/ws.sh +++ b/src/ws.sh @@ -1,69 +1,70 @@ -#!/bin/zsh -read bytes +#!/bin/bash -echo "bytes:" > /dev/stderr -echo $bytes | hexdump -C > /dev/stderr +# _ws_to_varlen(num) -> $num +_ws_to_varlen() { + if [[ $1 -lt 126 ]]; then + num=$(u8 "$1") + elif [[ $1 -ge 126 ]]; then # 2 bytes extended + num=7e$(u16 "$1") + elif [[ $1 -gt 65535 ]]; then # 8 bytes extended + num=7f$(u64 "$1") + fi +} -readarray -t data <<< $(echo -n $bytes | hexdump -e '/1 "%u\n"') +# _ws_from_varlen(len) -> $len +_ws_from_varlen() { + if [[ $1 -lt 126 ]]; then + len="$1" + return + elif [[ $1 == 126 ]]; then + bread 2 + elif [[ $1 == 127 ]]; then + bread 8 + else # what + return 1 + fi + len="$((0x$bres))" +} -echo "data:" > /dev/stderr -echo ${data[@]} | hexdump -C > /dev/stderr +# ws_send(payload) +ws_send() { + local num out len + len="${#1}" + [[ $len == 0 ]] && return + _ws_to_varlen $len + ( echo -n "81$num" | xxd -p -r; echo -n "$1" ) +} -echo "FIN bit:" $(( ${data[0]} >> 7 )) > /dev/stderr +ws_recv() { + local flags fin opcode mask mask_ len bres + if [[ "$1" ]]; then + local -n ws_res=$1 + fi + bread 2 || return 1 + flags=$bres -echo "opcode:" $(( ${data[0]} & 0x0f )) > /dev/stderr + fin=$(bit_slice $flags 15..16) + opcode=$(bit_slice $flags 8..12) + mask_=$(bit_slice $flags 7) + len=$(bit_slice $flags 0..7) + _ws_from_varlen "$len" # check for extended length -echo ${data[0]} > /dev/stderr + if [[ $mask_ == 1 ]]; then + bread 4 + # split into 4 separate bytes, separate them with dummy items at odd positions + # this is saving us a div/mul in the for loop below! :3 + mask=( ${bres:0:2} . ${bres:2:2} . ${bres:4:2} . ${bres:6:2} ) + fi -mask_bit=$(( ${data[1]} >> 7 )) -echo "Mask (bit):" $mask_bit > /dev/stderr - -len=$(( ${data[1]} & 0x7f )) -offset=2 # 2 for header - -if [[ $len == 126 ]]; then - len=$(( ${data[2]} << 8 + ${data[3]} )) - offset=4 # 2 for header, 2 for extended length -elif [[ $len == 127 ]]; then - len=0 - for i in {0..8}; do - len=$(( $len << 8 + ${data[2+i]} )) - done - offset=10 # 2 for header, 8 for extended length -fi - -echo "Data length:" $len > /dev/stderr - -echo "Offset:" $offset > /dev/stderr - -if [[ $mask_bit == 1 ]]; then - read -ra mask <<< ${data[@]:$offset:4} - - echo "Mask:" $mask > /dev/stderr - - offset=$(( $offset + 4 )) -fi - -read -ra payload <<< ${data[@]:$offset:$len} - -echo "Payload:" ${payload[@]} > /dev/stderr - -if [[ $mask_bit == 1 ]]; then - for ((i = 0; i < $len; i++)); do - byte=$(( ${payload[$i]} ^ ${mask[$i % 4]} )) - echo "Byte $i: $byte" > /dev/stderr - payload[$i]=$byte - done - - echo "Payload unmasked:" ${payload[@]} > /dev/stderr -fi - -payload_hex="" -for ((i = 0; i < $len; i++)); do - hex=$(printf '\\x%x\n' $(( ${payload[$i]} ))) - payload_hex+=$hex -done - -echo "Payload hex:" $payload_hex > /dev/stderr -echo -e "Payload: $payload_hex" > /dev/stderr + bread $len + if [[ $mask_ == 1 ]]; then + val=() + for (( i=0; i<(len*2); i=i+2 )); do + val+=($(( 0x${bres:i:2} ^ 0x${mask[i%8]} ))) + done + ws_res="$(printf '%x' ${val[@]} | unhex)" # TODO: variant that doesn't unhex + else + ws_res="$(unhex <<< "$bres")" + fi +} From ec0c81f9f5aa6df2662515ceaed807dfe83e614c Mon Sep 17 00:00:00 2001 From: sdomi Date: Wed, 26 Feb 2025 01:22:42 +0100 Subject: [PATCH 2/2] docs: websockets api documentation --- docs/websockets.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/websockets.md diff --git a/docs/websockets.md b/docs/websockets.md new file mode 100644 index 0000000..cf751a8 --- /dev/null +++ b/docs/websockets.md @@ -0,0 +1,76 @@ +# HTTP.sh and WebSockets + +WebSocket support is currently experimental; It has been rewritten from scratch and verified, but +it's a bit rough around the edges. Additionally, you have to manually obtain a copy of libsh +before using websockets: + + git clone https://git.sakamoto.pl/domi/libsh src/libsh + +Afterwards, you need to set `cfg[websocket_enable]` to `true` and optionally customize +`cfg[extension_websocket]`. Both of those options can be found in `config/master.sh`. + +It is **required** that all of your websocket handling scripts have a different extension than +your normal scripts. By default that's set to `shx` (opposed to default `shs` for normal scripts). +The way of interfacing with WebSockets is fundamentally different enough that it warrants wrapper +functions. While one could create scripts compatible with both, in most cases executing a script +with the wrong interface is NOT what we want to happen. + +## The API + +`ws.sh` exposes two main functions: `ws_recv` and `ws_send`. An example use is presented below: + + #!/bin/bash + while ws_recv; do + ws_send "hiii! here's your message: $ws_res" + done + +### ws_recv + +`ws_recv` waits for a new message, parses and unmasks it, then puts it into `$ws_res`. +This variable name can be overriden by doing `ws_recv varname`, which will then use `$varname` for +the payload. + +The return codes are: +- 0 for successful read +- 1 if EOF is encountered (analogous to builtin `read`) + +### ws_send + +`ws_send ` generates a header and sends the message out. + +## Note about async I/O + +Waiting for a message to arrive before sending out our data is not always useful. Thus, some use of +asynchronous I/O is in order. Bash doesn't make this very easy, but it's not impossible: + + #!/bin/bash + { + while sleep 1; do + ws_send "a" + done + } & + + while ws_recv; do + ws_send "$ws_res" + done + + pkill -P $$ + +This spawns a subshell which sends the letter "a". Then, we create a receive loop where we echo +everything back. Finally, and most importantly, when that receive loop quits, we kill all children +of the current shell process, cleaning up the previous subshell. IF THIS ISN'T DONE, BAD THINGS +HAPPEN TO YOUR SERVER - especially if your loop *doesn't* include a sleep like the example. + +With this style of async I/O, you have to take care of the IPC yourself. That is, no variables are +shared after the subshell spawns. The best way to handle this is to fully decouple the input and +output; If that's impossible, you may use a file approach (with or without notORM). + +## Quirks + +- Currently, anything manually sent to stdout will mangle the bitstream, + causing all subsequent I/O to fail. +- Endless loops are dangerous if not cleaned up correctly at the end of the file! + Additionally, you **need** a receive loop (it can be a dummy, with `:`) if you want to continously + send and cleanup after the connection closes; Otherwise there's nothing indicating whether the + connection is still alive or not. +- There's no way to transmit binary data w/o mangling it. TODO.