Last active
August 27, 2021 18:52
-
-
Save dubiouscript/e90a515b6ee2b5fe8988b945b0cdb418 to your computer and use it in GitHub Desktop.
web server written in bash
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
# | |
### | |
# https://gist.github.com/dubiouscript/e90a515b6ee2b5fe8988b945b0cdb418#this | |
# | |
# https://raw.githubusercontent.com/TooTallNate/bashttpd/master/bashttpd#origin | |
# | |
# https://raw.githubusercontent.com/TooTallNate/bashttpd/79582483264acad15b49e4b911f17f7bda753f43/bashttpd | |
### | |
# | |
# https://github.com/for-GET/literate-http | |
# https://learnxinyminutes.com/docs/bash/ | |
# https://github.com/pkrumins/bash-redirections-cheat-sheet | |
# http://www.binaryphile.com/bash/2018/07/26/approach-bash-like-a-developer-part-1-intro.html | |
# https://learnxinyminutes.com/docs/html/ | |
# https://developer.mozilla.org/en-US/docs/Web/HTML/Element | |
# https://html.spec.whatwg.org/ | |
# https://learnxinyminutes.com/docs/javascript/ | |
# https://learnxinyminutes.com/docs/json/ | |
# | |
# https://github.com/for-GET/know-your-http-well | |
# https://github.com/for-GET/know-your-http-well/blob/master/json/methods.json | |
# https://github.com/for-GET/know-your-http-well/blob/master/json/headers.json | |
# | |
# https://github.com/topics/http | |
# https://github.com/topics/http?l=shell | |
# | |
## | |
# | |
# A simple, configurable HTTP server written in bash. | |
# | |
# See LICENSE for licensing information. | |
# | |
# Original author: Avleen Vig, 2012 | |
# Reworked by: Josh Cartwright, 2012 | |
# Reworked by: Nathan Rajlich, 2018 | |
# +gzip ,ect: ~dscr | |
# If running as `root`, then downgrade to `nobody` first | |
if [ "$EUID" -eq 0 ]; then | |
exec sudo -u nobody "$0" "$@" | |
fi | |
conf="${BASHTTPD_CONFIG-bashttpd.conf}" | |
[ -r "${conf}" ] || { | |
cat > "${conf}" <<'EOF' | |
# | |
# bashttpd.conf - configuration for bashttpd | |
# | |
# The behavior of bashttpd is dictated by the evaluation | |
# of rules specified in this configuration file. Each rule | |
# is evaluated until one is matched. If no rule is matched, | |
# bashttpd will serve a 500 Internal Server Error. | |
# | |
# The format of the rules are: | |
# on_uri_match REGEX command [args] | |
# unconditionally command [args] | |
# | |
# on_uri_match: | |
# On an incoming request, the URI is checked against the specified | |
# (bash-supported extended) regular expression, and if encounters a match the | |
# specified command is executed with the specified arguments. | |
# | |
# For additional flexibility, on_uri_match will also pass the results of the | |
# regular expression match, ${BASH_REMATCH[@]} as additional arguments to the | |
# command. | |
# | |
# unconditionally: | |
# Always serve via the specified command. Useful for catchall rules. | |
# | |
# The following commands are available for use: | |
# | |
# serve_file FILE | |
# Statically serves a single file. | |
# | |
# serve_dir_with_tree DIRECTORY | |
# Statically serves the specified directory using 'tree'. It must be | |
# installed and in the PATH. | |
# | |
# serve_dir_with_ls DIRECTORY | |
# Statically serves the specified directory using 'ls -al'. | |
# | |
# serve_dir DIRECTORY | |
# Statically serves a single directory listing. Will use 'tree' if it is | |
# installed and in the PATH, otherwise, 'ls -al' | |
# | |
# serve_dir_or_file_from DIRECTORY | |
# Serves either a directory listing (using serve_dir) or a file (using | |
# serve_file). Constructs local path by appending the specified root | |
# directory, and the URI portion of the client request. | |
# | |
# serve_static_string STRING | |
# Serves the specified static string with Content-Type text/plain. | |
# | |
# Examples of rules: | |
# | |
# on_uri_match '^/issue$' serve_file "/etc/issue" | |
# | |
# When a client's requested URI matches the string '/issue', serve them the | |
# contents of /etc/issue | |
# | |
# on_uri_match 'root' serve_dir / | |
# | |
# When a client's requested URI has the word 'root' in it, serve up | |
# a directory listing of / | |
# | |
# DOCROOT=/var/www/html | |
# on_uri_match '/(.*)' serve_dir_or_file_from "$DOCROOT" | |
# When any URI request is made, attempt to serve a directory listing | |
# or file content based on the request URI, by mapping URI's to local | |
# paths relative to the specified "$DOCROOT" | |
# | |
# | |
# | |
match_between() { sed -n '/'"$1"'/,/'"$2"'/p'; } | |
##### | |
html_Page() { head="$1"; body="$2"; serve_static_string "<html><head> $head </head><body> $body </body></html>" "text/html" ; } | |
html_link() { while read link text ; do [ -z "$link" ] || echo '<a href='"$link>${text:-$link}"'</a></br>' ; done ; } | |
########## | |
# unconditionally serve_static_string 'Hello, world! You can configure bashttpd by modifying bashttpd.conf.' | |
## http://humanstxt.org/ | |
## | |
humans() { serve_static_string "$(cat $0 | match_between "^# Original author" "^$" | head -n-1 )" ; } | |
# | |
on_uri_match '^/humans.txt$' humans | |
## | |
svr_self() { serve_static_string "$(cat $0)" ; } | |
# | |
on_uri_match '^/bashttpd$' svr_self | |
## | |
svr_cfg() { serve_static_string "$( cat "${conf}" )" ; } | |
# | |
on_uri_match '^/bashttpd.conf$' svr_cfg | |
### | |
strip_comments() { sed '/^[[:blank:]]*#/d;s/#.*//' ; } | |
svr_lst_routes() { cat "${conf}" | strip_comments | grep -e 'on_ur''i_match' | awk '{print $2}' | sed -e "s/^'\^//g" -e "s/\$'$//g" -e "s/\*'$//g" -e "s/'$//g" ; } | |
default_route() { mod_cfg='Hello, world! You can configure bashttpd by modifying '"${conf}."; | |
page_head="<title> Slash Bin Slash Bash </title>"; | |
page_body="$( svr_lst_routes | sort | uniq | html_link) </br> $mod_cfg</hr>"; | |
html_Page "$page_head" "$page_body"; | |
} | |
## | |
# | |
on_uri_match '^/$' default_route | |
# More about commands: | |
# | |
# It is possible to somewhat easily write your own commands. An example | |
# may help. The following example will serve "Hello, $x!" whenever | |
# a client sends a request with the URI /say_hello_to/$x: | |
# | |
# serve_hello() { | |
# set_response_header "Content-Type" "text/plain" | |
# echo "Hello, $2!" | |
# } | |
# on_uri_match '^/say_hello_to/(.*)$' serve_hello | |
# | |
# Like mentioned before, the contents of ${BASH_REMATCH[@]} are passed | |
# to your command, so its possible to use regular expression groups | |
# to pull out info. | |
# | |
# With this example, when the requested URI is /say_hello_to/Josh, serve_hello | |
# is invoked with the arguments '/say_hello_to/Josh' 'Josh', | |
# (${BASH_REMATCH[0]} is always the full match) | |
EOF | |
echo "Created bashttpd.conf using defaults." | |
echo "Please review it/configure before running bashttpd again." | |
exit 1 | |
} | |
recv() { echo "<" "$@" >&2; } | |
send() { echo ">" "$@" >&2; | |
printf "%s\r\n" "$*"; } | |
read_bytes() { | |
LANG=C IFS= read -r -d '' -n "$1" char | |
printf "%s" "${char}" | |
} | |
DATE=$(date +"%a, %d %b %Y %H:%M:%S %Z") | |
declare -a RESPONSE_HEADERS=( | |
"Date: $DATE" | |
"Expires: $DATE" | |
"Server: Slash Bin Slash Bash" | |
) | |
add_response_header() { | |
RESPONSE_HEADERS+=("$1: $2") | |
} | |
_add_response_header() { | |
RESPONSE_HEADERS+=("$1") | |
} | |
# https://github.com/for-GET/know-your-http-well/blob/master/json/status-codes.json | |
declare -a HTTP_RESPONSE=( | |
[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" | |
[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 Entity" | |
[423]="Locked" | |
[424]="Failed Dependency" | |
[425]="Unordered Collection" | |
[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" | |
[509]="Bandwidth Limit Exceeded" | |
[510]="Not Extended" | |
[511]="Network Authentication Required" | |
) | |
send_response_header() { | |
local code=$1 | |
send "HTTP/1.0 ${code} ${HTTP_RESPONSE[${code}]}" | |
for i in "${RESPONSE_HEADERS[@]}"; do | |
send "$i" | |
done | |
send | |
} | |
_fail_with() { | |
send_response_header "$1" | |
echo "$1 ${HTTP_RESPONSE[$1]}" | |
exit 1 | |
} | |
# Request-Line HTTP RFC 2616 $5.1 | |
IFS='' read -r line || _fail_with 400 | |
# strip trailing CR if it exists | |
line=${line%%$'\r'} | |
recv "$line" | |
read -r REQUEST_METHOD REQUEST_URI REQUEST_HTTP_VERSION <<<"$line" | |
[ -n "$REQUEST_METHOD" ] && \ | |
[ -n "$REQUEST_URI" ] && \ | |
[ -n "$REQUEST_HTTP_VERSION" ] \ | |
|| _fail_with 400 | |
REQUEST_PATH="${REQUEST_URI%%\?*}" | |
QUERY_STRING="${REQUEST_URI#*\?}" | |
declare -a REQUEST_HEADERS | |
while IFS='' read -r line; do | |
line=${line%%$'\r'} | |
recv "$line" | |
# If we've reached the end of the headers, break. | |
[ -z "$line" ] && break | |
REQUEST_HEADERS+=("$line") | |
done | |
CONTROL_SEQUENCE=$'\1' | |
flush_response() { | |
# Wait for response code and header "events" from stdin | |
local code=200 | |
local buf | |
while true; do | |
local buf="$(read_bytes "${#CONTROL_SEQUENCE}")" | |
#recv "buf: $(printf "%x " "'${buf}") ${#buf}" | |
if [ "${buf}" = "${CONTROL_SEQUENCE}" ]; then | |
IFS='' read -r line | |
line=${line%%$'\r'} | |
#recv "line: ${line}" | |
local cmd="${line:0:1}" | |
local data="${line:1}" | |
case "${cmd}" in | |
C) code="${data}";; | |
H) _add_response_header "${data}";; | |
esac | |
else | |
break | |
fi | |
done | |
send_response_header "${code}" | |
printf "%s" "${buf}" | |
cat | |
} | |
# Set the response status code. | |
# MUST be called *before* any output is generated by your script. | |
set_response_code() { | |
echo "${CONTROL_SEQUENCE}C$1" | |
} | |
# Sets a response header. | |
# MUST be called *before* any output is generated by your script. | |
set_response_header() { | |
local header="$1" | |
shift | |
if [ $# -ne 0 ]; then | |
header="${header}: $*" | |
fi | |
echo "${CONTROL_SEQUENCE}H${header}" | |
} | |
fail_with() { | |
set_response_code "$1" | |
echo "$1 ${HTTP_RESPONSE[$1]}" | |
return 1 | |
} | |
serve_file() { | |
local file="$( realpath $1)"; # works with links | |
local length="$(stat -c'%s' "$file")" | |
if [ $? -ne 0 ]; then | |
fail_with 404 | |
else | |
CONTENT_TYPE= | |
case "$file" in | |
*\.css) | |
CONTENT_TYPE="text/css" | |
;; | |
*\.js) | |
CONTENT_TYPE="text/javascript" | |
;; | |
*) | |
CONTENT_TYPE="$(file -b --mime-type "$file")" | |
;; | |
esac | |
set_response_header "Content-Type" "$CONTENT_TYPE" | |
# https://github.com/for-GET/know-your-http-well/blob/3cc5ab6d2764ab7aacb1b6e026abaccbeb6c37f2/json/headers.json#L21 | |
read -r CONTENT_LENGTH < <(stat -c'%s' "$file") && \ | |
set_response_header "Content-Length" "$CONTENT_LENGTH"; | |
# https://github.com/for-GET/know-your-http-well/blob/3cc5ab6d2764ab7aacb1b6e026abaccbeb6c37f2/json/headers.json#L27 Content-Length | |
# if encodeing is acceptable then set encoding to gzip | |
get_request_header "Accept-Encoding" | grep -q -e "gzip" && set_response_header "Content-encoding" "gzip"; | |
# https://github.com/for-GET/know-your-http-well/blob/3cc5ab6d2764ab7aacb1b6e026abaccbeb6c37f2/json/headers.json#L3 Content-encoding | |
cat "$file" | encodeing_gzip | |
#sleep 0.5; | |
fi | |
} | |
serve_dir_with_tree() { | |
local dir="$1" tree_vers tree_opts basehref x | |
set_response_header "Content-Type" "text/html" | |
# The --du option was added in 1.6.0. | |
read x tree_vers x < <(tree --version) | |
[[ $tree_vers == v1.6* ]] && tree_opts="--du" | |
tree -H "$2" -L 1 "$tree_opts" -D "$dir" | |
} | |
serve_dir_with_ls() { | |
local dir=$1 | |
set_response_header "Content-Type" "text/plain" | |
ls -la "$dir" | |
} | |
serve_dir() { | |
# If `tree` is installed, use that for pretty output. | |
if which tree &>/dev/null; then | |
serve_dir_with_tree "$@" | |
else | |
serve_dir_with_ls "$@" | |
fi | |
} | |
serve_dir_or_file_from() { | |
local root="$1" | |
local file="${3-${2-}}" | |
local URL_PATH="${root}/${file}" | |
# sanitize URL_PATH | |
URL_PATH=${URL_PATH//[^a-zA-Z0-9_~\-\.\/]/} | |
[[ $URL_PATH == *..* ]] && fail_with 400 | |
# Serve index file if exists in requested directory | |
[[ -d $URL_PATH && -f $URL_PATH/index.html && -r $URL_PATH/index.html ]] && \ | |
URL_PATH="$URL_PATH/index.html" | |
if [[ -f $URL_PATH ]] && [[ -r $URL_PATH ]]; then | |
serve_file "$URL_PATH" "$@" | |
elif [[ -d $URL_PATH ]] && [[ -x $URL_PATH ]]; then | |
serve_dir "$URL_PATH" "$@" | |
fi | |
} | |
serve_static_string() { | |
set_response_header "Content-Type" "${2-"text/plain"}" | |
set_response_header "Content-Length" "$( echo "$1" | wc -c )" | |
# https://github.com/for-GET/know-your-http-well/blob/3cc5ab6d2764ab7aacb1b6e026abaccbeb6c37f2/json/headers.json#L27 | |
echo "$1" | |
} | |
# https://stackoverflow.com/a/37840948/376773 | |
decode_url() { : "${*//+/ }"; echo -e "${_//%/\\x}"; } | |
get_request_header() { | |
local name="$1" | |
shopt -s nocasematch | |
for header in "${REQUEST_HEADERS[@]}"; do | |
if [[ "${header}" == "${name}:"* ]]; then | |
local index="$((${#name} + 2))" | |
echo "${header:$index}" | |
return 0 | |
fi | |
done | |
# If we got to here then the header was not found | |
return 1 | |
} | |
get_request_body() { | |
local length="$(get_request_header "content-length")" | |
if [ ! -z "${length}" ]; then | |
recv "Reading ${length} byte request body" | |
read_bytes "${length}" | |
else | |
local encoding="$(get_request_header "transfer-encoding")" | |
if [ "${encoding}" = "chunked" ]; then | |
while IFS='' read -r line; do | |
line=${line%%$'\r'} | |
#recv "$line" | |
length="$(printf "%d" "0x${line%%$'\n'}")" | |
if [ "${length}" -gt 0 ]; then | |
recv "Reading ${length} byte chunk" | |
read_bytes "${length}" | |
# The next two bytes are supposed to be '\r\n' | |
#recv "Reading 2 byte end" | |
# TODO: add verification | |
read_bytes 2 > /dev/null | |
recv "Done with chunk" | |
else | |
recv "Done reading chunked body" | |
break | |
fi | |
done | |
fi | |
fi | |
} | |
on_uri_match() { | |
local regex=$1 | |
shift | |
[[ $REQUEST_URI =~ $regex ]] && "$@" "${BASH_REMATCH[@]}" | |
} | |
unconditionally() { | |
"$@" "$REQUEST_URI" | |
} | |
run_user_code() { | |
source "${conf}" | |
} | |
# https://github.com/for-GET/know-your-http-well/blob/3cc5ab6d2764ab7aacb1b6e026abaccbeb6c37f2/json/headers.json#L135 Accept-Encoding | |
# https://web.archive.org/web/20170929183935/https://georgik.rocks/cgi-server-bash-one-liner/#gzip | |
encodeing_gzip() { get_request_header "Accept-Encoding"| grep -q -e "gzip" ; [ $? -eq 0 ] && gzip || cat ; } | |
get_request_body | run_user_code | flush_response |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment