Skip to content

Instantly share code, notes, and snippets.

@TerrorBite
Last active February 5, 2021 11:37
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save TerrorBite/ddc9e72fc02d09febd22753a0a47c947 to your computer and use it in GitHub Desktop.
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
#!/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