Last active
February 5, 2021 11:37
-
-
Save TerrorBite/ddc9e72fc02d09febd22753a0a47c947 to your computer and use it in GitHub Desktop.
A webserver… written as a shell script. This is a terrible idea. MIT license
This file contains 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
#!/bin/bash | |
# Let's write a webserver in Bash because haha why not | |
# Copyright (c) 2017-2020 TerrorBite <terrorbite@lethargiclion.net> | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining a copy | |
# of this software and associated documentation files (the "Software"), to deal | |
# in the Software without restriction, including without limitation the rights | |
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
# copies of the Software, and to permit persons to whom the Software is | |
# furnished to do so, subject to the following conditions: | |
# | |
# The above copyright notice and this permission notice shall be included in all | |
# copies or substantial portions of the Software. | |
# | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
# SOFTWARE. | |
# FEATURES: | |
# - Can handle multiple simultaneous connections | |
# - Can run both HTTP and HTTPS | |
# - Directory listing if directory is requested | |
# - Supports Connection: keep-alive | |
# - Supports gzip compression if requested by the browser | |
# - Supports redirect to HTTPS if requested by the browser | |
# - Supports If-None-Match/If-Modified-Since (sends Last-Modified and ETag) | |
# NOT SUPPORTED: | |
# - Any request that is not a GET request (no support for POST, HEAD, ...) | |
# - being run under any shell other than bash | |
if [ -z ${BASH_VERSION+bash} ]; then | |
echo "It appears this script is not being run in the Bash shell." | |
echo "This script relies upon Bash features, and is NOT portable." | |
echo "It probably isn't possible to make a POSIX version of this script." | |
exit | |
fi | |
# Settings (readonly) | |
: SETTINGS; { | |
# What ports shall we listen on? | |
declare -r bashttpd_listen_port=8080 bashttpd_listen_port_ssl=8443 # Listening ports | |
# What is our canonical hostname? | |
declare -r bashttpd_hostname=lethargiclion.net | |
#What directory is our webroot in? (Default is the current directory where we were launched) | |
declare -r bashttpd_webroot=$(pwd) | |
# Where is our access log? (Default is in /tmp for no particular reason) | |
declare -r bashttpd_access_log='/tmp/bashttpd.access.log' | |
# Should HTTP requests be redirected to HTTPS? | |
# No: Don't ever redirect HTTP to HTTPS | |
# Yes: Redirect to HTTPS if the client asks via Upgrade-Insecure-Requests | |
# Force: Always redirect any HTTP request to HTTPS | |
declare -r bashttpd_redirect_to_https=No | |
# Where are our HTTPS certificate and key files located? | |
declare -r certfile=/tmp/$bashttpd_hostname.bashttpd.crt.pem keyfile=/tmp/$bashttpd_hostname.bashttpd.key.pem | |
} | |
# Declare arrays | |
declare -A request_headers response_headers | |
declare -a status | |
declare -A settings | |
# HTTP status text | |
status[100]="Continue" | |
status[101]="Switching Protocols" | |
status[200]="OK" | |
status[201]="Created" | |
status[202]="Accepted" | |
status[203]="Non-Authoritative Information" | |
status[204]="No Content" | |
status[205]="Reset Content" | |
status[206]="Partial Content" # Used in reply to Range header | |
status[300]="Multiple Choices" | |
status[301]="Moved Permanently" | |
status[302]="Found" | |
status[303]="See Other" | |
status[304]="Not Modified" # Used with If-Modified-Since, If-Match | |
status[305]="Use Proxy" | |
status[307]="Temporary Redirect" # Used for redirecting HTTP to HTTPS, etc | |
status[400]="Bad Request" | |
status[402]="Payment Required" | |
status[403]="Forbidden" | |
status[404]="Not Found" | |
status[405]="Method Not Allowed" | |
status[406]="Not Acceptable" | |
status[408]="Request Timeout" | |
status[409]="Conflict" | |
status[410]="Gone" | |
status[416]="Range Not Satisfiable" # Used in reply to invalid Range header | |
status[418]="I'm a teapot" # Easter egg | |
status[500]="Internal Server Error" | |
status[501]="Not Implemented" # Used for POST, etc | |
status[502]="Bad Gateway" | |
status[503]="Service Unavailable" | |
status[504]="Gateway Timeout" | |
status[505]="HTTP Version Not Supported" # Used when someone requests HTTP2 | |
urlencode() { | |
# I don't really understand how this works, but it does so hey | |
local LC_ALL=C str="$1" safe='' | |
while [[ -n "$str" ]]; do | |
safe="${str%%[!a-zA-Z0-9/:_\.\-\!\'\(\)~]*}" | |
printf "%s" "$safe" | |
str="${str#"$safe"}" | |
if [[ -n "$str" ]]; then | |
printf "%%%02X" "'$str" | |
str="${str#?}" | |
fi | |
done | |
} | |
urldecode() { | |
local str="${1//+/%20}" | |
str="${1//\\/%5C}" | |
printf '%b' "${str//%/\\x}" | |
} | |
set_header() { | |
response_headers["$1"]="$2" | |
} | |
filesize() { | |
stat --printf="%s" "$1" | |
} | |
set_content_length() { | |
set_header Content-Length $(stat --printf='%s' "$1") | |
} | |
stderr() { | |
echo "$(date) [$BASHPID]: $@" >&2 | |
} | |
write_access_log() { | |
local -i responsecode="$1" bytes="${response_headers[Content-Length]}" | |
local referrer="${request_headers[referer]:+\"${request_headers[referer]}\"}" | |
referrer="${referrer:--}" | |
local useragent="${request_headers[user-agent]:+\"${request_headers[user-agent]}\"}" | |
useragent="${useragent:--}" | |
local cookie='-' #lazy | |
# NCSA Combined log format | |
local logline="${remote%:*} - - $(date +'[%d/%b/%Y:%H:%M:%S %z]') \"${request}\" $responsecode $bytes $referrer $useragent $cookie" | |
# File locking to prevent workers overwriting each other | |
exec 7<> "$bashttpd_access_log" | |
flock --exclusive 7 | |
echo "$logline" >> "$bashttpd_access_log" | |
flock --unlock 7 | |
exec 7>&- | |
} | |
get_mime_type() { | |
local file_mime="$(file -ib "$1")" | |
local charset="${file_mime#*; }" | |
case "${1##*.}" in | |
html|htm) | |
echo "text/html; $charset" ;; | |
css) | |
echo "text/css; $charset" ;; | |
js) | |
echo "application/javascript; $charset" ;; | |
json) | |
echo "application/json; $charset" ;; | |
*) | |
# use file utility to determine mime type | |
file -ib "$1" ;; | |
esac | |
} | |
respond() { | |
# Sends an HTTP response header. Doesn't send body. | |
echo -e "HTTP/1.1 $1 ${status[$1]-Unknown Status}\r" | |
for header in "${!response_headers[@]}"; do | |
echo -e "$header: ${response_headers[$header]}\r" | |
done | |
echo -e "\r" | |
[[ -f "$2" ]] && cat "$2" | |
write_access_log $1 | |
} | |
send_error() { | |
local text="$2"$'\r\n' | |
set_header Content-Length ${#text} | |
set_header Content-Type "text/plain; charset=US-ASCII" | |
respond $1 | |
echo -n "$text" | |
} | |
serve_file() { | |
local filename="$1" | |
local etag | |
# try crc32 etag method, fall back to slower md5 method | |
printf -v etag "\"%x-%x\"" $(cksum < "$filename") | |
[[ ${#etag} -lt 8 ]] && printf -v etag "\"md5:%s\"" "$(md5sum "$filename" | cut -d' ' -f1)" | |
# alternate methods, don't need these | |
#local etag="\"$(echo -ne $(md5sum < bashttpd.sh | sed -e 's/..$//;s/../\\x&/g') | base64 | tr -d =)\"" | |
#local etag="\"$(openssl dgst -sha1 -binary bashttpd.sh | base64 | tr -d =)\"" | |
local -i should_serve=1 should_range=1 | |
if [[ -n ${request_headers['if-modified-since']} ]]; then | |
local -i mydate="$(stat --format="%Y" "$filename")" | |
local -i yourdate="$(date --date="${request_headers['if-modified-since']}" +%s)" | |
if [[ $mydate -le $yourdate ]]; then should_serve=0; fi | |
fi | |
if [[ -n ${request_headers['if-none-match']} ]]; then | |
if [[ "$etag" == "${request_headers['if-none-match']}" ]]; then should_serve=0; fi | |
fi | |
local -i response=200 | |
if [[ $should_serve -eq 1 ]]; then | |
if [[ -n ${request_headers['if-range']} ]]; then | |
local validator="${request_headers['if-range']}" | |
if [[ "$validator" == *\" ]]; then | |
# it's an ETag | |
if [[ "$etag" == "$validator" ]]; then should_range=0; fi | |
else | |
# it's a date | |
local -i mydate="$(stat --format="%Y" "$filename")" | |
local -i yourdate="$(date --date="$validator" +%s)" | |
if [[ $mydate -le $yourdate ]]; then should_range=0; fi | |
fi | |
fi | |
set_header Accept-Ranges bytes # Advertise that we support byte ranges | |
if [[ -n ${request_headers['range']} && $should_range -eq 1 ]]; then | |
if [[ ${request_headers['range']} == bytes=* ]]; then | |
local bytes="${request_headers['range']#bytes=}" | |
if [[ "$bytes" == *,* ]]; then | |
send_error 416 "Sorry, I don't know how to send multiple ranges at once" | |
return | |
fi | |
bytes="${bytes//[^0-9-]}" # sanitize | |
local -i begin end len filelen | |
filelen="$(filesize "$filename")" | |
bytes="${bytes/%-/-$filelen}" # if no end supplied, use filesize | |
begin="${bytes%%-*}" end="${bytes#*-}" | |
let len=$end+1-$begin | |
stderr "Range requested: bytes $begin-$end (len $len) for: $filename" | |
# Verify that the range is valid | |
if [[ $begin -le 0 || $begin -gt $end || $end -gt $filelen ]]; then | |
# Invalid range requested | |
send_error 416 "Range you requested is invalid" | |
return | |
fi | |
local tmpfile=$(mktemp -p /tmp "http${BASHPID}_XXXXX.tmp") | |
tail -c +$begin "$filename" | head -c $len > "$tmpfile" | |
set_header Content-Range "bytes ${begin}-${end}/$(filesize "$filename")" | |
filename="$tmpfile" | |
response=206 #Partial Content | |
else | |
# Got Range request that wasn't bytes as unit | |
send_error 416 "I don't understand the range units you're asking for" | |
return | |
fi | |
fi | |
# Set headers, ready for sending | |
set_header Content-Type "$(get_mime_type "$filename")" | |
set_header ETag "$etag" | |
local mdate="$(date --date="$(stat --format="%y" "$filename")" --rfc-2822)" | |
set_header Last-Modified "$mdate" | |
if [[ ${request_headers['accept-encoding']} == *'gzip'* && $(filesize "$filename") -gt 128 ]]; then | |
echo "$(date) [$BASHPID]: Serving file (gzipped): $filename" >&2 | |
local gztmpfile=$(mktemp -p /tmp "http${BASHPID}_XXXXX.gz") | |
gzip -c "$filename" > "$gztmpfile" | |
set_header Content-Encoding gzip | |
filename="$gztmpfile" | |
else | |
echo "$(date) [$BASHPID]: Serving file: $filename" >&2 | |
fi | |
set_content_length "$filename" | |
respond $response "$filename" | |
cat "$filename" | |
# remove tempfile if it exists | |
local pattern="/tmp/http${BASHPID}_?????.*" | |
if [[ -f $pattern ]]; then rm $pattern; fi | |
else | |
set_header Content-Length 0 | |
respond 304 | |
fi | |
} | |
serve_directory() { | |
echo "Listing directory: $1" >&2 | |
local uponelevel="" | |
local parent="${2%/*/}/" | |
if [[ "$2" != "/" ]]; then uponelevel="<a href=\"..\">Up one level</a>"; fi | |
local html="<!DOCTYPE html> | |
<html> | |
<head><title>Directory listing for $2</title></head> | |
<body> | |
<h1>Directory listing for $2</h1> | |
$uponelevel | |
<ul> | |
$(ls -p "$1" | sed -r 's/^(.*$)$/ <li><a href="\1">\1<\/a><\/li>/') | |
</ul> | |
<hr> | |
<em>bashttpd server - HTTP(S) server written entirely in bash script</em> | |
</body> | |
</html>" | |
set_header Content_Type "text/html; charset=US-ASCII" | |
if [[ ${request_headers['accept-encoding']} == *'gzip'* ]]; then | |
# Browser accepts gzip. Send gzipped data | |
local tmpfile=$(mktemp -p /tmp http_XXXXX.gz) | |
echo "$html" | gzip -c > "$tmpfile" | |
set_header Content-Encoding gzip | |
set_header Content-Length $(stat --printf="%s" "$tmpfile") | |
respond 200 | |
cat "$tmpfile" | |
rm "$tmpfile" | |
else | |
set_header Content-Length ${#html} | |
respond 200 | |
echo -n "$html" | |
fi | |
} | |
serve_file_gzip() { | |
#echo "Serving file (gzipped): $1" >&2 | |
# This function isn't used: see serve_file() | |
: | |
} | |
process_request() { | |
local remote="$1" | |
local proto="${2-http}" | |
local method path version query | |
# Read start of HTTP request, timeout after 5 minutes | |
read -r -t $timeout method path version || { | |
if [[ $! > 127 ]]; then | |
echo "$(date) [$BASHPID]: Read timed out: $remote" >&2 | |
else | |
echo "$(date) [$BASHPID]: Remote closed the connection: $remote" >&2 | |
fi | |
return 1 | |
} | |
if [[ -z $method ]]; then | |
echo "$(date) [$BASHPID]: $remote sent empty line, hanging up" >&2 | |
return 1 | |
else | |
# save request for logging purposes | |
request="$method $path ${version%%$'\r'}" | |
local origpath="$path" | |
# split at first question mark, into path and query string | |
path="${origpath%%'?'*}" query="${origpath#*'?'}" | |
# NOTE: we don't actually use the query string | |
fi | |
# Clear any existing headers | |
request_headers=() response_headers=() | |
while true; do | |
IFS='' read -r -t 5 header | |
header=${header%%$'\r'} | |
if [[ ${#header} -eq 0 ]]; then break; fi | |
#echo "${#header} $header" >&2 | |
local -l headername="${header%%: *}" | |
request_headers[$headername]="${header#*: }" | |
done | |
set_header Connection close | |
set_header Server "bashttpd/1.0 ($(uname -o); bash $BASH_VERSION)" | |
set_header Date "$(date --rfc-2822)" | |
# Check that this is HTTP 1.x | |
if [[ ${version:0:7} != 'HTTP/1.' ]]; then | |
respond 505 #HTTP Version Not Supported | |
echo "$(date) [$BASHPID]: 505: Got unsupported HTTP version from $remote" >&2 | |
return 1 # close connection | |
fi | |
# Check that Host header is set | |
if [[ -z ${request_headers['host']} ]]; then | |
send_error 400 "You didn't send a Host: header" | |
echo "$(date) [$BASHPID]: 400: missing Host header from $remote" >&2 | |
return 1 | |
fi | |
# ok, we've successfully read a valid HTTP header. Future timeouts will be longer | |
timeout=300 | |
# If on HTTP, check Upgrade-Insecure-Requests header | |
if [[ $proto == 'http' ]]; then | |
if [[ ${bashttpd_redirect_to_https,,} == 'force' || \ | |
( ${bashttpd_redirect_to_https,,} == 'yes' && ${request_headers['upgrade-insecure-requests']} -eq 1 ) ]]; then | |
# We are on HTTP, but the client wants to use HTTPS | |
local host=${request_headers['host']%:*} # Get host header and strip off port | |
set_header Content-Length 0 | |
if [[ $bashttpd_listen_port_ssl -ne 443 ]]; then | |
# our HTTPS isn't on the default port 443, so include the port | |
set_header Location "https://$host:$bashttpd_listen_port_ssl$path" | |
else | |
set_header Location "https://$host$path" | |
fi | |
respond 307 # temp redirect | |
echo "$(date) [$BASHPID]: 307: redirecting $remote to HTTPS" >&2 | |
return 1 # close connection because we are switching protocols | |
fi | |
fi | |
set_header Connection "${request_headers['connection']-close}" | |
case "$method" in | |
GET) | |
if [[ "$path" != /* ]]; then | |
set_header Content-Length 0 | |
set_header Connection close | |
respond 400 | |
return 1 | |
fi | |
# Resolve requested filename | |
local filename="$(realpath -m "./$(urldecode "${path:1}")")" | |
if [[ $filename != $(pwd)* ]]; then | |
# Attempted directory traversal attack, deny it | |
echo "$(date) [$BASHPID]: Denying 'GET $path' (file $filename)" >&2 | |
send_error 403 "Forbidden: Requested file is not in webroot" | |
elif [[ -d "$filename" ]]; then | |
# Requested path is a directory | |
if [[ "$path" != */ ]]; then | |
# If path doesn't end in a slash then redirect to the right address | |
set_header Location "$path/" | |
set_header Content-Length 0 | |
respond 307 | |
return 0 # keepalive | |
fi | |
# Look for an index file | |
if [[ -f "$filename/index.html" ]]; then | |
serve_file "$filename/index.html" | |
else | |
#TODO: Setting to switch between allowing and denying dir listings | |
#send_error 403 "Forbidden: Directory listings are not allowed" | |
serve_directory "$filename" "$(urldecode "$path")" | |
fi | |
#echo "$(date) [$BASHPID]: DEBUG: Finished serving index file or directory to $remote" >&2 | |
elif [[ -f "$filename" ]]; then | |
if [[ -r "$filename" ]]; then | |
# Serve file | |
serve_file "$filename" | |
else | |
echo "$(date) [$BASHPID]: File not readable: $filename" >&2 | |
send_error 403 "Forbidden: The requested file is not readable" | |
fi | |
#echo "$(date) [$BASHPID]: DEBUG: Finished serving requested file to $remote" >&2 | |
else | |
echo "$(date) [$BASHPID]: No such file: $filename" >&2 | |
send_error 404 "Not found: $http_path" | |
fi | |
echo "$(date) [$BASHPID]: Finished GET request for $remote" >&2 | |
;; | |
BREW) | |
send_error 418 "Cannot brew coffee: this is a teapot" | |
;; | |
*) | |
if [[ "$method" =~ [[:upper:]][[:upper:]]+ ]]; then | |
# Sounds like an HTTP method we don't support | |
set_header Allow GET | |
send_error 501 "This server only supports HTTP GET requests." | |
else | |
# Received something that doesn't look like an HTTP method | |
# Client is probably not sending HTTP, so just hang up | |
echo "$(date) [$BASHPID]: Got garbage from $remote - hanging up on them" >&2 | |
return 1 | |
fi;; | |
esac | |
#echo "$(date) [$BASHPID]: DEBUG: Finished processing request from $remote" >&2 | |
if [[ ${request_headers['connection']} == 'keep-alive' ]]; then | |
echo "$(date) [$BASHPID]: Keeping connection with $remote" >&2 | |
return 0 | |
fi | |
echo "$(date) [$BASHPID]: Closing connection with $remote" >&2 | |
return 1 | |
} | |
accept() { | |
#echo "$(date) [$BASHPID]: DEBUG: Worker $BASHPID started" >&2 | |
# Accepts incoming connections. Stdin and stdout go to the network. | |
cd "$bashttpd_webroot" | |
# use lsof on our parent to get the remote address | |
local remote="$(lsof -nP -p$PPID -a -i | tail -n1 | sed -r 's/^.*->(\S+).*$/\1/')" | |
if [[ -z $remote ]]; then | |
echo "$(date) [$BASHPID]: Connection from unknown remote, aborting (worker exiting)" | |
return | |
fi | |
echo "$(date) [$BASHPID]: Accepted $1 connection from $remote" >&2 | |
declare -gi timeout=5 # Set initial accept timeout to 5 seconds | |
# Process incoming HTTP requests until connection closed | |
while process_request "$remote" "$1"; do : ; done | |
#echo "$(date) [$BASHPID]: DEBUG: Worker $BASHPID exiting" >&2 | |
} | |
# --accept means the script is being executed as a worker thread. | |
# Stdin and stdout are connected to remote host | |
if [[ $1 == '--accept' ]]; then | |
accept http; exit | |
elif [[ $1 == '--accept-ssl' ]]; then | |
accept https; exit | |
fi | |
#coproc ncpipe { ncat -k -l $bashttpd_listen_port --exec "bash $1 --accept"; } | |
cleanup() { | |
echo -e "\rCaught Ctrl-C, shutting down listeners..." >&2 | |
kill -HUP $(jobs -p) | |
} | |
if [[ -f "$keyfile" && -f "$certfile" ]]; then | |
echo "Using existing cert/key files for SSL" | |
else | |
echo "Generating new self-signed cert/key files for SSL" | |
openssl req -new -nodes -x509 -sha256 -keyout $keyfile -out $certfile -subj "/C=AU/O=bashttpd/CN=$bashttpd_hostname" | |
fi | |
read -r common_name < <( | |
# this is disgustingly hacky | |
eval $(openssl x509 -in $certfile -subject -noout | tr / $'\n') | |
echo "$CN" | |
) | |
echo -n "Using HTTPS certificate issued to ‘$common_name’ with serial number " | |
openssl x509 -in "$certfile" -serial -noout | cut -d= -f2 | |
echo 'Certificate fingerprints:' | |
echo -n " SHA-256 " | |
openssl x509 -in "$certfile" -sha256 -fingerprint -noout | cut -d= -f2 | |
echo -n " SHA1 " | |
openssl x509 -in "$certfile" -sha1 -fingerprint -noout | cut -d= -f2 | |
# Launch socat or ncat as listener process. | |
# The listener will fork a worker for each accepted connection | |
# This works a lot like a usermode inetd | |
if command -v socat >/dev/null; then | |
socat TCP4-LISTEN:$bashttpd_listen_port,reuseaddr,fork EXEC:"$SHELL -- $0 --accept" & | |
socat OPENSSL-LISTEN:$bashttpd_listen_port_ssl,reuseaddr,fork,verify=0,certificate="$certfile",key="$keyfile" EXEC:"$SHELL -- $0 --accept-ssl" & | |
echo "Now listening using socat listeners" >&2 | |
elif command -v ncat >/dev/null; then | |
echo "socat is not available, falling back to ncat" >&2 | |
ncat --keep-open --listen $bashttpd_listen_port --exec "$SHELL -- $0 --accept" & | |
ncat --keep-open --listen $bashttpd_listen_port_ssl --ssl --ssl-cert "$certfile" --ssl-key "$keyfile" --exec "$SHELL -- $0 --accept-ssl" & | |
echo "Now listening using ncat listeners" >&2 | |
else | |
echo "No listener available (tried socat, ncat)" >&2 | |
echo "Recommend installing socat" >&2 | |
exit 1 | |
fi | |
trap cleanup SIGINT | |
echo "http://$bashttpd_hostname:$bashttpd_listen_port/" | |
echo "https://$bashttpd_hostname:$bashttpd_listen_port_ssl/" | |
wait | |
# Clean up stray temp files | |
#rm /tmp/http[0-9]*[0-9]_?????.* | |
exit | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment