diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a73b85a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y socat ncat bats curl openssl + + - name: Build single-file artifact + # Exercise the Makefile + inliner so the single-file build can't bitrot. + # (The single_file.bats test under tests/integration also rebuilds it.) + run: make build + + - name: Smoke-test the single-file artifact + run: dist/bashttpd version + + - name: Run test suite + run: | + if [[ -x ./tests/run ]]; then + ./tests/run + else + bats --recursive tests/ + fi + + lint: + name: Lint (shellcheck) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + + - name: Run shellcheck + run: shellcheck bashttpd bin/bashttpd lib/bashttpd/*.sh scripts/*.sh + + - name: Build and shellcheck the single-file artifact + # The generated dist/bashttpd must stay lint-clean too. + run: | + make build + shellcheck dist/bashttpd diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7fbb451 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +# On any pushed v* tag: build the self-contained single file and publish it as +# a release asset named `bashttpd`, so that +# curl -LO https://github.com/avleen/bashttpd/releases/latest/download/bashttpd +# fetches a ready-to-run server. + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Build and publish single-file release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # so `git describe --tags` can stamp the build + + - name: Build single-file artifact + run: make build + + - name: Smoke-test the artifact + run: | + test -x dist/bashttpd + dist/bashttpd version + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + files: dist/bashttpd + fail_on_unmatched_files: true + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..299c806 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Build artifacts. The single-file build is produced on demand / in CI and +# attached to releases; it is never committed. +/dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b495cba --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +# bashttpd -- developer Makefile (GNU make). +# +# make build build the self-contained single file into dist/bashttpd +# make test run the bats test suite (./tests/run) +# make lint run shellcheck over every shell source +# make check lint + test +# make clean remove dist/ + +SHELL := /bin/bash + +LIB_SOURCES := $(wildcard lib/bashttpd/*.sh) +DIST := dist/bashttpd + +SHELL_SOURCES := bashttpd bin/bashttpd $(LIB_SOURCES) $(wildcard scripts/*.sh) + +.PHONY: build test lint check clean + +build: $(DIST) + +$(DIST): bin/bashttpd $(LIB_SOURCES) scripts/build-single.sh + ./scripts/build-single.sh + +test: + ./tests/run + +lint: + shellcheck $(SHELL_SOURCES) + +check: lint test + +clean: + rm -rf dist diff --git a/README.md b/README.md index 04cb1ee..39023b7 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,284 @@ -bashttpd is a simple, configurable web server written in bash +# bashttpd -Requirements -------------- +An HTTP/1.1 server written entirely in `bash`. No, really. - 1. `bash`, any recent version should work - 2. `socat` or `netcat` to handle the underlying sockets. - 3. A healthy dose of insanity +bashttpd exists to answer a question nobody asked: how far can you push +`read`, `printf`, and parameter expansion before it starts to look like a +web server? The answer, after the 2.0 overhaul, is: keep-alive, TLS, +regex-based routing, directory listings, and a config DSL — all without a +single line of Perl, Python, or Node. It's genuinely useful for quick static +serving, local demos, CI fixtures, and inetd/socat vintage deployments. It is +not, and will never be, something you point at the internet. -Examples ---------- +## Requirements - socat TCP4-LISTEN:8080 EXEC:/usr/local/bin/bashttpd +- `bash` >= 4 (associative arrays and friends) +- `socat` or `ncat` — one of the two, to accept connections and hand them to + the server +- `openssl` — only if you want `--tls` +- `curl` and `bats` — only for running the test suite -Or +## Quick start - netcat -lp 8080 -e ./bashttpd +```console +$ bin/bashttpd init-config # writes ./bashttpd.conf from the example +$ bin/bashttpd serve -p 8080 -d ./public +bashttpd 2.0 listening on http://0.0.0.0:8080 (socat) + docroot: /path/to/public +``` -Note that in the `socat` example above, the web server will immediately exit once the first connection closes. If you wish to serve to more than one client - like most servers do, then use the variant: +With no `-d`/`-c` and no `bashttpd.conf` in the current directory, `serve` +just answers every `GET`/`HEAD` with a small "Hello, world!" string, so you +always have something to curl: - socat TCP4-LISTEN:8080,fork EXEC:/usr/local/bin/bashttpd +```console +$ curl http://localhost:8080/ +Hello, world! bashttpd is running. Configure it with a bashttpd.conf or -d DOCROOT. +``` -This way, a new process is spawned for each incoming connection. +### Single-file build +The modular tree is convenient to hack on, but sometimes you just want one +script to drop on a box. `make build` inlines the library modules into the CLI +and writes a fully self-contained `dist/bashttpd` — it needs no `lib/` +directory and behaves exactly like `bin/bashttpd` for every subcommand: -Getting started ----------------- +```console +$ make build +$ dist/bashttpd serve -p 8080 -d ./public # runs on its own, anywhere +``` - 1. Running bashttpd for the first time will generate a default configuration file, bashttpd.conf - 2. Review bashttpd.conf and configure it as you want. - 3. Run bashttpd using netcat or socat, as listed above. +Every tagged release also ships that artifact, so you can grab a ready-to-run +server without cloning: -Features ---------- +```console +$ curl -LO https://github.com/avleen/bashttpd/releases/latest/download/bashttpd +$ chmod +x bashttpd && ./bashttpd serve -d ./public +``` - 1. Serves text and HTML files - 2. Shows directory listings - 3. Allows for configuration based on the client-specified URI +`make test` runs the bats suite and `make lint` runs shellcheck over every +shell source (`make check` does both). -Limitations ------------- +## CLI reference - 1. Does not support authentication - 2. Doesn't strictly adhere to the HTTP spec. +``` +Usage: bashttpd [serve] [options] + bashttpd handle (serve one connection on stdin/stdout) + bashttpd init-config [FILE] (write an example config) + bashttpd version | help +``` -Security --------- +`serve` is the default subcommand if none is given. - 1. Only rudimentary input handling. We would not running this on a public machine. +| Option | Default | Description | +|---|---|---| +| `-p`, `--port PORT` | `8080` (`8443` with `--tls`) | Listen port | +| `-a`, `--bind ADDR` | `0.0.0.0` | Bind address | +| `-c`, `--config FILE` | `./bashttpd.conf` if present | Config file (see below) | +| `-d`, `--docroot DIR` | — | Serve this directory when no config is given | +| `--tls` | off | Enable TLS (requires `--cert` and `--key`) | +| `--cert FILE` | — | TLS certificate (PEM) | +| `--key FILE` | — | TLS private key (PEM) | +| `--timeout SECS` | `5` | Idle / keep-alive read timeout | +| `--max-requests N` | `100` | Max requests served per connection | +| `-v`, `--verbose` | off | Verbose logging to stderr | +| `--inetd` | — | Alias for the `handle` subcommand | +| `--version` | — | Print version and exit | +| `-h`, `--help` | — | Show usage | -HTTP protocol support ---------------------- +Other subcommands: - 403: Returned when a directory is not listable, or a file is not readable - 400: Returned when the first word of the first line is not `GET` - 200: Returned with valid content - Content-type: Bashttpd uses /usr/bin/file to determine the MIME type to sent to the browser - 1.0: The server doesn't support Host: headers or other HTTP/1.1 features - it barely supports HTTP/1.0! +- **`handle`** — serve exactly one connection on stdin/stdout, then exit. + This is what `serve` execs per accepted connection (via socat/ncat's + "run this command" mode), and it's also the entry point for classic + inetd-style invocation. +- **`init-config [FILE]`** — copy `etc/bashttpd.conf.example` to `FILE` + (default `bashttpd.conf`). Refuses to overwrite an existing file. +- **`version`** — print `bashttpd 2.0`. +- **`help`** — print the usage block above. -As always, your patches/pull requests are welcome! +`serve` picks `socat` if it's on `PATH`, otherwise falls back to `ncat`. If +neither is installed, it exits with an error telling you to invoke `handle` +directly from your own listener. -Testimonials ------------- +## Configuration -"If anyone installs that anywhere, they might meet a gruesome end with a rusty fork" - --- BasHTTPd creator, maintainer +`bashttpd.conf` is an ordinary bash script, sourced fresh for **every** +request. Its job is to call routing directives; the first one that matches +handles the request and later directives are skipped — same "first match +wins" model as legacy bashttpd, just without `exit`ing the process each time. + +Request state available to your config: + +| Variable | Meaning | +|---|---| +| `REQUEST_METHOD` | `GET`, `POST`, etc. | +| `REQUEST_URI` | raw target, e.g. `/a/b%20c?x=1` | +| `REQUEST_PATH` | decoded, canonicalized path, e.g. `/a/b c` | +| `QUERY_STRING` | everything after `?` | +| `REQUEST_HTTP_VERSION` | `HTTP/1.0` or `HTTP/1.1` | +| `HTTP_HOST` | the `Host:` header value | +| `REQUEST_BODY` | request body for POST/PUT, up to the size cap | + +Routing directives: + +- `on_uri_match REGEX COMMAND [ARGS...]` — if `REGEX` matches + `REQUEST_PATH`, run `COMMAND ARGS... "${BASH_REMATCH[@]}"` (so `$1` is the + whole match, `$2` the first capture group, and so on). +- `on_method_match METHOD COMMAND [ARGS...]` — run `COMMAND` when the + request method equals `METHOD` (case-insensitive). +- `unconditionally COMMAND [ARGS...]` — catch-all; always dispatches if + nothing has matched yet. The decoded path is appended as the last argument. + +Built-in content commands: + +- `serve_file FILE` — send one file, MIME type guessed from its extension + (falling back to `file(1)`, then `application/octet-stream`). +- `serve_dir DIRECTORY` — an HTML directory listing, rendered in pure bash. +- `serve_dir_with_ls DIRECTORY` — legacy plain-text `ls -la` listing. +- `serve_dir_or_file_from DOCROOT` — map `REQUEST_PATH` under `DOCROOT`: + serves `index.html` if present, otherwise a file or a listing, and issues + a `301` to add a trailing slash when a directory is requested without one. +- `serve_static_string STRING` — serve a literal string as `text/plain`. +- `redirect_to URL [CODE]` — redirect (default `302`). + +Legacy aliases still work, so old-style config files drop in unchanged: +`add_response_header`, `fail_with`, `send_response_ok_exit`. + +A custom handler using a regex capture group — `GET /say_hello_to/Ada` +returns `Hello, Ada!`: + +```bash +serve_hello() { + add_response_header 'Content-Type' 'text/plain; charset=utf-8' + send_response_ok_exit <<< "Hello, $2!" +} +on_uri_match '^/say_hello_to/(.*)$' serve_hello +``` + +See `etc/bashttpd.conf.example` for the full annotated example, including a +static-docroot fallback. + +## TLS + +Generate a throwaway self-signed cert (EC, `CN=localhost`, SANs for +`localhost`/`127.0.0.1`, 365 days): + +```console +$ scripts/gen-cert.sh # writes certs/server.crt, certs/server.key +``` + +Then serve over HTTPS (default TLS port is `8443`): + +```console +$ bin/bashttpd --tls --cert certs/server.crt --key certs/server.key -d ./public +$ curl -k https://localhost:8443/ +``` + +`-k` is required because it's a self-signed cert — bashttpd is not in the +business of getting you a real one. + +## Classic socat / inetd usage + +The top-level `bashttpd` file (not `bin/bashttpd`) is a compatibility shim +for the original invocation style. It forwards to `bin/bashttpd`, and when +called with no arguments and stdin isn't a terminal (i.e. it's a socket from +socat/inetd), it drops straight into `handle` mode: + +```console +$ socat TCP4-LISTEN:8080,fork EXEC:/path/to/bashttpd +``` + +Or the traditional `netcat -e` style: + +```console +$ netcat -lp 8080 -e ./bashttpd +``` + +Note that without `,fork`, `socat` exits after the first connection closes. + +## Architecture + +`bin/bashttpd` is a thin CLI: it parses options, then either execs +`socat`/`ncat` as a forking listener (`serve`) or sources the library and +handles one connection directly (`handle`). Everything that matters lives in +`lib/bashttpd/`, split by concern so each piece can be read (and tested) on +its own: + +``` +bashttpd # root compat shim for classic socat/inetd usage +bin/bashttpd # CLI: option parsing, subcommands, listener exec +lib/bashttpd/ + core.sh # version constant, logging, core_init + http.sh # status text, URL decode, path canonicalization, + # request-line/header parsing, response writer + mime.sh # extension -> Content-Type table + file(1) fallback + router.sh # config DSL: on_uri_match / on_method_match / + # unconditionally + handlers.sh # serve_file, serve_dir, serve_dir_or_file_from, + # redirects, legacy aliases + server.sh # the per-connection request loop (framing, + # keep-alive, limits, dispatch, access log) +etc/bashttpd.conf.example # annotated example config +scripts/gen-cert.sh # self-signed cert generator for --tls +tests/ # bats test suite +``` + +## HTTP/1.1 feature list + +What's actually supported: + +- Request line + header parsing with sane limits (max header/request-line + length, max header count, max body size). +- `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `PATCH` dispatched to your config; + `OPTIONS` answered directly with `204` + `Allow`; anything else gets `501`. +- Keep-alive: HTTP/1.1 connections stay open (idle timeout + a max-requests + cap per connection) unless `Connection: close` is sent; HTTP/1.0 is + close-by-default unless `Connection: keep-alive` is sent. +- `Host:` header required on HTTP/1.1 requests (`400` if missing). +- `Content-Length` request bodies, capped and drained even if the handler + ignores them. +- Correct `Content-Length`, `Date`, `Server`, and `Connection` response + headers on every response; a small HTML error page for non-2xx statuses. + +What's explicitly **not** supported, on purpose: + +- Chunked (or any other) request `Transfer-Encoding` — rejected with `501`. +- Byte-range requests (`Range:` / `206 Partial Content`). +- Conditional requests (`If-Modified-Since`, `ETag`, `304`) — `serve_file` + sends `Last-Modified` but nothing ever checks it. +- Path safety is **lexical only**: `REQUEST_PATH` has `.`/`..`/`//` resolved + and can't climb above the root, but symlinks inside your docroot are + followed as-is. Don't symlink your docroot to something you don't want + served. + +## Testing + +```console +$ tests/run # if present +$ bats --recursive tests/ +``` + +Tests exercise the HTTP layer with real `curl` requests against a `handle` +or `serve` instance, plus unit-level bats checks on the library functions +(URL decoding, path canonicalization, MIME lookup, etc.). + +## Security + +This is still a bash script parsing untrusted input from a raw socket. It's +better-behaved than the 1.x line — real status codes, request limits, no +symlink-following footguns beyond what's noted above — but it is a toy +implementation of HTTP, not a hardened one. Do not run it on a public +interface. Do not put it in front of anything you care about. You have been +warned, twice now. + +## License + +MIT-style; see [LICENSE](LICENSE). + +## Testimonials + +> "If anyone installs that anywhere, they might meet a gruesome end with a +> rusty fork" +> +> — BasHTTPd creator, maintainer diff --git a/bashttpd b/bashttpd index 9ed8d21..74d0fff 100755 --- a/bashttpd +++ b/bashttpd @@ -1,282 +1,29 @@ #!/usr/bin/env bash # -# A simple, configurable HTTP server written in bash. +# bashttpd -- compatibility shim for bashttpd 2.x. # -# See LICENSE for licensing information. +# The real implementation lives in bin/bashttpd and lib/bashttpd/. This shim +# keeps the classic invocation working: # -# Original author: Avleen Vig, 2012 -# Reworked by: Josh Cartwright, 2012 - -warn() { echo "WARNING: $@" >&2; } - -[ -r bashttpd.conf ] || { - cat >bashttpd.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" +# socat TCP-LISTEN:8080,fork EXEC:/path/to/bashttpd # -> handle mode +# netcat -lp 8080 -e ./bashttpd # -> handle mode # -# 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" -# - -unconditionally serve_static_string 'Hello, world! You can configure bashttpd by modifying bashttpd.conf.' - -# 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() { -# add_response_header "Content-Type" "text/plain" -# send_response_ok_exit <<< "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 - warn "Created bashttpd.conf using defaults. Please review it/configure before running bashttpd again." - exit 1 -} - -recv() { echo "< $@" >&2; } -send() { echo "> $@" >&2; - printf '%s\r\n' "$*"; } - -[[ $UID = 0 ]] && warn "It is not recommended to run bashttpd as root." - -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") -} - -declare -a HTTP_RESPONSE=( - [200]="OK" - [400]="Bad Request" - [403]="Forbidden" - [404]="Not Found" - [405]="Method Not Allowed" - [500]="Internal Server Error" -) - -send_response() { - local code=$1 - send "HTTP/1.0 $1 ${HTTP_RESPONSE[$1]}" - for i in "${RESPONSE_HEADERS[@]}"; do - send "$i" - done - send - while read -r line; do - send "$line" - done -} - -send_response_ok_exit() { send_response 200; exit 0; } - -fail_with() { - send_response "$1" <<< "$1 ${HTTP_RESPONSE[$1]}" - exit 1 -} - -serve_file() { - local file=$1 - - CONTENT_TYPE= - case "$file" in - *\.css) - CONTENT_TYPE="text/css" - ;; - *\.js) - CONTENT_TYPE="text/javascript" - ;; - *) - read -r CONTENT_TYPE < <(file -b --mime-type "$file") - ;; - esac - - add_response_header "Content-Type" "$CONTENT_TYPE"; - - read -r CONTENT_LENGTH < <(stat -c'%s' "$file") && \ - add_response_header "Content-Length" "$CONTENT_LENGTH" - - send_response_ok_exit < "$file" -} - -serve_dir_with_tree() -{ - local dir="$1" tree_vers tree_opts basehref x - - add_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" - - send_response_ok_exit < \ - <(tree -H "$2" -L 1 "$tree_opts" -D "$dir") -} - -serve_dir_with_ls() -{ - local dir=$1 - - add_response_header "Content-Type" "text/plain" - - send_response_ok_exit < \ - <(ls -la "$dir") -} - -serve_dir() { - local dir=$1 - - # If `tree` is installed, use that for pretty output. - which tree &>/dev/null && \ - serve_dir_with_tree "$@" - - serve_dir_with_ls "$@" - - fail_with 500 -} - -serve_dir_or_file_from() { - local URL_PATH=$1/$3 - shift - - # 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 ]]; then - [[ -r $URL_PATH ]] && \ - serve_file "$URL_PATH" "$@" || fail_with 403 - elif [[ -d $URL_PATH ]]; then - [[ -x $URL_PATH ]] && \ - serve_dir "$URL_PATH" "$@" || fail_with 403 - fi - - fail_with 404 -} - -serve_static_string() { - add_response_header "Content-Type" "text/plain" - send_response_ok_exit <<< "$1" -} - -on_uri_match() { - local regex=$1 - shift - - [[ $REQUEST_URI =~ $regex ]] && \ - "$@" "${BASH_REMATCH[@]}" -} - -unconditionally() { - "$@" "$REQUEST_URI" -} - -# Request-Line HTTP RFC 2616 $5.1 -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 - -# Only GET is supported at this time -[ "$REQUEST_METHOD" = "GET" ] || fail_with 405 - -declare -a REQUEST_HEADERS +# When invoked with no arguments and stdin is not a terminal (i.e. it's a +# socket/pipe from socat/inetd/netcat), we serve that one connection. Otherwise +# we forward all arguments to bin/bashttpd's CLI. -while read -r line; do - line=${line%%$'\r'} - recv "$line" +SELF=$(readlink -f "${BASH_SOURCE[0]}") +DIR=${SELF%/*} +REAL="$DIR/bin/bashttpd" - # If we've reached the end of the headers, break. - [ -z "$line" ] && break +if [[ ! -x $REAL ]]; then + printf 'bashttpd: %s not found or not executable\n' "$REAL" >&2 + exit 1 +fi - REQUEST_HEADERS+=("$line") -done +# No args + stdin is a socket/pipe -> classic "socat EXEC" usage. +if [[ $# -eq 0 && ! -t 0 ]]; then + exec "$REAL" handle +fi -source "${BASH_SOURCE[0]%/*}"/bashttpd.conf -fail_with 500 +exec "$REAL" "$@" diff --git a/bin/bashttpd b/bin/bashttpd new file mode 100755 index 0000000..88bca13 --- /dev/null +++ b/bin/bashttpd @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# +# bin/bashttpd -- main executable for bashttpd 2.x. +# +# Subcommands: +# serve Start a listener (default). Execs socat (or ncat) which forks +# a `handle` child per connection. +# handle Serve one connection on stdin/stdout (inetd / socat EXEC mode). +# init-config Write an example bashttpd.conf. +# version Print the version. +# help Print usage. +# +# The server options are passed to `handle` children through the environment +# (BASHTTPD_*), which is far more robust than quoting args through socat. + +set -o pipefail + +# @@INLINE_LIBS_BEGIN@@ +# --- Locate ourselves and the library --------------------------------------- +# NOTE: everything between the @@INLINE_LIBS_BEGIN@@ / @@INLINE_LIBS_END@@ +# markers is replaced by scripts/build-single.sh when producing the +# self-contained dist/bashttpd (the lib modules are inlined and load_lib +# becomes a no-op). Keep the markers in place. +SELF=$(readlink -f "${BASH_SOURCE[0]}") +BIN_DIR=${SELF%/*} +REPO_DIR=${BIN_DIR%/*} +LIB_DIR=${BASHTTPD_LIB_DIR:-$REPO_DIR/lib/bashttpd} + +load_lib() { + local m + for m in core http mime router handlers server; do + # shellcheck source=/dev/null + source "$LIB_DIR/$m.sh" || { + printf 'bashttpd: failed to load %s\n' "$LIB_DIR/$m.sh" >&2 + exit 1 + } + done +} +# @@INLINE_LIBS_END@@ + +usage() { + cat <<'EOF' +Usage: bashttpd [serve] [options] + bashttpd handle (serve one connection on stdin/stdout) + bashttpd init-config [FILE] (write an example config) + bashttpd version | help + +Options (serve): + -p, --port PORT Listen port (default 8080; 8443 with --tls) + -a, --bind ADDR Bind address (default 0.0.0.0) + -c, --config FILE Config file (default ./bashttpd.conf if present) + -d, --docroot DIR Serve this directory when no config is given + --tls Enable TLS (requires --cert and --key) + --cert FILE TLS certificate (PEM) + --key FILE TLS private key (PEM) + --timeout SECS Idle/keep-alive timeout (default 5) + --max-requests N Max requests per connection (default 100) + -v, --verbose Verbose logging to stderr + --inetd Alias for `handle` + --version Print version and exit + -h, --help Show this help + +TLS certificates can be generated with scripts/gen-cert.sh. +EOF +} + +version() { + # Sourced constant if available, else a literal fallback. + printf 'bashttpd %s\n' "${BASHTTPD_VERSION:-2.0}" +} + +die() { + printf 'bashttpd: %s\n' "$1" >&2 + exit "${2:-1}" +} + +# --- Determine subcommand ---------------------------------------------------- +SUBCMD='serve' +case ${1:-} in + serve|handle|init-config|version|help) + SUBCMD=$1; shift ;; + --inetd) + SUBCMD='handle'; shift ;; + --version) + SUBCMD='version'; shift ;; + -h|--help) + SUBCMD='help'; shift ;; +esac + +# --- Option defaults --------------------------------------------------------- +# Defaults inherit from the environment so that `handle` children (which receive +# their configuration via BASHTTPD_* from the serve parent) don't clobber it +# when these values are re-exported below. +PORT='' +BIND='0.0.0.0' +CONFIG=${BASHTTPD_CONFIG:-} +DOCROOT=${BASHTTPD_DOCROOT:-} +TLS=0 +CERT='' +KEY='' +TIMEOUT=${BASHTTPD_TIMEOUT:-5} +MAX_REQUESTS=${BASHTTPD_MAX_REQUESTS:-100} +VERBOSE=${BASHTTPD_VERBOSE:-0} + +# --- Parse options ----------------------------------------------------------- +while (( $# )); do + case $1 in + -p|--port) PORT=$2; shift 2 ;; + -a|--bind) BIND=$2; shift 2 ;; + -c|--config) CONFIG=$2; shift 2 ;; + -d|--docroot) DOCROOT=$2; shift 2 ;; + --tls) TLS=1; shift ;; + --cert) CERT=$2; shift 2 ;; + --key) KEY=$2; shift 2 ;; + --timeout) TIMEOUT=$2; shift 2 ;; + --max-requests) MAX_REQUESTS=$2; shift 2 ;; + -v|--verbose) VERBOSE=1; shift ;; + --version) SUBCMD='version'; shift ;; + -h|--help) SUBCMD='help'; shift ;; + --inetd) SUBCMD='handle'; shift ;; + --) shift; break ;; + -*) die "unknown option: $1" ;; + *) break ;; + esac +done + +# --- Simple subcommands ------------------------------------------------------ +case $SUBCMD in + help) + usage; exit 0 ;; + version) + # Load lib for the real version constant, but don't fail if missing. + # shellcheck source=lib/bashttpd/core.sh + [[ -r $LIB_DIR/core.sh ]] && source "$LIB_DIR/core.sh" + version; exit 0 ;; + init-config) + dest=${1:-bashttpd.conf} + example="$REPO_DIR/etc/bashttpd.conf.example" + [[ -r $example ]] || die "example config not found at $example" + if [[ -e $dest ]]; then + die "refusing to overwrite existing $dest" + fi + cp -- "$example" "$dest" || die "could not write $dest" + printf 'Wrote example config to %s\n' "$dest" + exit 0 ;; +esac + +# --- Resolve config defaults ------------------------------------------------- +# If no --config was given, use ./bashttpd.conf when it exists; otherwise fall +# back to built-in behavior (no file is written to disk automatically). This +# auto-detection only runs for serve mode -- handle children take their config +# purely from the inherited environment. +if [[ $SUBCMD != 'handle' && -z $CONFIG && -r ./bashttpd.conf ]]; then + CONFIG=$(readlink -f ./bashttpd.conf) +fi +if [[ -n $CONFIG && ! -r $CONFIG ]]; then + die "config file not readable: $CONFIG" +fi +if [[ -n $DOCROOT ]]; then + DOCROOT=$(readlink -f "$DOCROOT" 2>/dev/null) || die "bad docroot" + [[ -d $DOCROOT ]] || die "docroot is not a directory: $DOCROOT" +fi + +# --- Export environment for handle children ---------------------------------- +export BASHTTPD_CONFIG=$CONFIG +export BASHTTPD_DOCROOT=$DOCROOT +export BASHTTPD_TIMEOUT=$TIMEOUT +export BASHTTPD_MAX_REQUESTS=$MAX_REQUESTS +export BASHTTPD_VERBOSE=$VERBOSE +export BASHTTPD_LIB_DIR=$LIB_DIR + +# --- handle mode: serve one connection -------------------------------------- +if [[ $SUBCMD == 'handle' ]]; then + load_lib + core_init + server_handle_connection + exit 0 +fi + +# --- serve mode: exec a socket listener ------------------------------------- +# Port default depends on TLS. +if [[ -z $PORT ]]; then + if (( TLS )); then PORT=8443; else PORT=8080; fi +fi +[[ $PORT == +([0-9]) ]] || die "invalid port: $PORT" + +if (( TLS )); then + [[ -n $CERT && -n $KEY ]] || die "--tls requires --cert and --key (see scripts/gen-cert.sh)" + [[ -r $CERT ]] || die "certificate not readable: $CERT" + [[ -r $KEY ]] || die "key not readable: $KEY" +fi + +# The command each listener runs for an accepted connection. +HANDLER_CMD="$SELF handle" + +start_msg() { + local scheme='http' + (( TLS )) && scheme='https' + printf 'bashttpd %s listening on %s://%s:%s (%s)\n' \ + "${BASHTTPD_VERSION:-2.0}" "$scheme" "$BIND" "$PORT" "$1" >&2 + if [[ -n $CONFIG ]]; then + printf ' config: %s\n' "$CONFIG" >&2 + elif [[ -n $DOCROOT ]]; then + printf ' docroot: %s\n' "$DOCROOT" >&2 + else + printf ' serving built-in hello-world (no config/docroot)\n' >&2 + fi +} + +if command -v socat >/dev/null 2>&1; then + start_msg 'socat' + if (( TLS )); then + exec socat \ + "OPENSSL-LISTEN:$PORT,bind=$BIND,cert=$CERT,key=$KEY,verify=0,reuseaddr,fork" \ + EXEC:"$HANDLER_CMD" + else + exec socat \ + "TCP-LISTEN:$PORT,bind=$BIND,reuseaddr,fork" \ + EXEC:"$HANDLER_CMD" + fi +elif command -v ncat >/dev/null 2>&1; then + start_msg 'ncat' + if (( TLS )); then + exec ncat --listen --keep-open --ssl --ssl-cert "$CERT" --ssl-key "$KEY" \ + --sh-exec "$HANDLER_CMD" "$BIND" "$PORT" + else + exec ncat --listen --keep-open --sh-exec "$HANDLER_CMD" "$BIND" "$PORT" + fi +else + die "neither socat nor ncat found; cannot listen (use 'handle' via inetd/socat instead)" +fi diff --git a/etc/bashttpd.conf.example b/etc/bashttpd.conf.example new file mode 100644 index 0000000..7b856e6 --- /dev/null +++ b/etc/bashttpd.conf.example @@ -0,0 +1,80 @@ +# +# bashttpd.conf - example configuration for bashttpd 2.x +# +# The config is an ordinary bash script, sourced once per request. Its job is +# to route the request by calling directives. The FIRST directive that matches +# handles the request; later directives are skipped. If nothing matches, +# bashttpd returns 404 (GET/HEAD) or 405 (other methods). +# +# Available request variables (set before this file is sourced): +# REQUEST_METHOD e.g. GET, POST +# REQUEST_URI raw target, e.g. /a/b%20c?x=1 +# REQUEST_PATH decoded, canonicalized path, e.g. /a/b c +# QUERY_STRING everything after '?', e.g. x=1 +# REQUEST_HTTP_VERSION HTTP/1.0 or HTTP/1.1 +# HTTP_HOST the Host header value +# REQUEST_BODY request body (for POST/PUT, up to the size cap) +# +# --------------------------------------------------------------------------- +# Routing directives +# --------------------------------------------------------------------------- +# +# on_uri_match REGEX COMMAND [ARGS...] +# If REGEX matches REQUEST_PATH, run COMMAND with ARGS plus the regex +# capture groups (${BASH_REMATCH[@]}) appended -- same as legacy bashttpd. +# +# on_method_match METHOD COMMAND [ARGS...] +# Run COMMAND when the request method equals METHOD. +# +# unconditionally COMMAND [ARGS...] +# Catch-all. The decoded path is appended as the final argument. +# +# --------------------------------------------------------------------------- +# Content commands +# --------------------------------------------------------------------------- +# +# serve_file FILE Serve a single file (MIME auto-detected). +# serve_dir DIRECTORY HTML directory listing (pure bash). +# serve_dir_with_ls DIRECTORY Plain-text `ls -la` listing (legacy). +# serve_dir_or_file_from DOCROOT Map REQUEST_PATH under DOCROOT; serves +# index.html, files, or a listing; issues a +# 301 to add a trailing slash for dirs. +# serve_static_string STRING Serve a literal string as text/plain. +# redirect_to URL [CODE] Redirect (default 302). +# +# Legacy aliases still work: add_response_header, fail_with, +# send_response_ok_exit. +# +# --------------------------------------------------------------------------- +# Examples +# --------------------------------------------------------------------------- + +# Serve /etc/issue at exactly /issue: +# on_uri_match '^/issue$' serve_file /etc/issue + +# A custom dynamic handler using a regex capture group. When a client requests +# /say_hello_to/Ada, this serves "Hello, Ada!". +# +# on_uri_match appends ${BASH_REMATCH[@]} to the command's arguments, exactly +# like legacy bashttpd: $1 is the full match (BASH_REMATCH[0]) and $2 is the +# first capture group (BASH_REMATCH[1]). +serve_hello() { + add_response_header 'Content-Type' 'text/plain; charset=utf-8' + send_response_ok_exit <<< "Hello, $2!" +} +on_uri_match '^/say_hello_to/(.*)$' serve_hello + +# Static site: serve everything else from a document root. +DOCROOT=${BASHTTPD_DOCROOT:-/var/www/html} +unconditionally serve_dir_or_file_from "$DOCROOT" + +# If no rule above matched, bashttpd returns 404 automatically. + +# --------------------------------------------------------------------------- +# TLS +# --------------------------------------------------------------------------- +# TLS is configured on the command line, not here: +# scripts/gen-cert.sh # writes certs/server.{crt,key} +# bin/bashttpd --tls --cert certs/server.crt --key certs/server.key \ +# -d /var/www/html +# The default TLS port is 8443. diff --git a/lib/bashttpd/core.sh b/lib/bashttpd/core.sh new file mode 100644 index 0000000..abaedda --- /dev/null +++ b/lib/bashttpd/core.sh @@ -0,0 +1,45 @@ +# shellcheck shell=bash +# lib/bashttpd/core.sh +# +# Core setup for bashttpd: version constants, logging helpers, and a couple +# of small utilities shared across the other modules. Sourced first, so the +# constants and functions defined here are available everywhere. + +# Version / server identity. Bumped from the legacy 1.x line. +BASHTTPD_VERSION='2.0' +# shellcheck disable=SC2034 # consumed by http.sh/server.sh response headers +SERVER_TOKEN="bashttpd/${BASHTTPD_VERSION}" + +# --------------------------------------------------------------------------- +# Logging. Everything goes to stderr so it never contaminates the HTTP +# response written to stdout. log_info is gated behind BASHTTPD_VERBOSE. +# --------------------------------------------------------------------------- + +log_info() { + [[ ${BASHTTPD_VERBOSE:-0} == 1 ]] && printf '[info] %s\n' "$*" >&2 + return 0 +} + +log_warn() { + printf '[warn] %s\n' "$*" >&2 + return 0 +} + +log_error() { + printf '[error] %s\n' "$*" >&2 + return 0 +} + +# core_init: one-time per-process setup for the request handler. Warns when +# running as root (a bad idea for a toy HTTP server) and applies sane shell +# options. We intentionally avoid `set -e` in the request hot path: a single +# failing builtin should not tear down a live connection. +core_init() { + # Do not let a broken pipe (client hung up) kill the process abruptly; + # the request loop checks write/read results instead. + trap '' PIPE + + if [[ ${EUID:-$(id -u)} -eq 0 ]]; then + log_warn 'running as root is not recommended.' + fi +} diff --git a/lib/bashttpd/handlers.sh b/lib/bashttpd/handlers.sh new file mode 100644 index 0000000..16b52e4 --- /dev/null +++ b/lib/bashttpd/handlers.sh @@ -0,0 +1,194 @@ +# shellcheck shell=bash +# lib/bashttpd/handlers.sh +# +# Content handlers invoked by the router DSL: static files, directory listings +# (rendered in pure bash, no `tree` dependency), docroot serving with traversal +# protection, redirects, and small compatibility shims for the legacy config +# vocabulary (add_response_header, fail_with, send_response_ok_exit). + +# html_escape STRING -> HTML-escaped string on stdout. Pure parameter +# expansion, no external processes. +html_escape() { + local s=$1 + s=${s//&/&} + s=${s//<} + s=${s//>/>} + s=${s//\"/"} + printf '%s' "$s" +} + +# serve_file FILE -- send a single file with a guessed Content-Type. +serve_file() { + local file=$1 + if [[ ! -f $file ]]; then + res_send_error 404 + return + fi + if [[ ! -r $file ]]; then + res_send_error 403 + return + fi + res_add_header 'Content-Type' "$(mime_type_for "$file")" + res_add_header 'Last-Modified' "$(LC_ALL=C TZ=UTC date -r "$file" '+%a, %d %b %Y %H:%M:%S GMT' 2>/dev/null)" + res_send 200 -f "$file" +} + +# serve_dir DIRECTORY -- render an HTML directory listing in pure bash. +serve_dir() { + local dir=$1 + local disp=${REQUEST_PATH:-/} + local esc_disp entry name href rows='' body + + esc_disp=$(html_escape "$disp") + + # Parent link (except at the docroot's displayed root). + if [[ $disp != '/' ]]; then + rows+=$'
%s
+ + +' "$esc_disp" "$esc_disp" "$rows" "$SERVER_TOKEN" + + res_add_header 'Content-Type' 'text/html; charset=utf-8' + res_send 200 -s "$body" +} + +# serve_dir_with_ls DIRECTORY -- legacy compatibility: plain-text `ls -la`. +serve_dir_with_ls() { + local dir=$1 out + out=$(ls -la -- "$dir" 2>/dev/null) + res_add_header 'Content-Type' 'text/plain; charset=utf-8' + res_send 200 -s "$out"$'\n' +} + +# serve_dir_or_file_from DOCROOT -- map the request path under DOCROOT and serve +# a file or directory listing. Relies on the already-canonicalized REQUEST_PATH +# (no '..' can survive canonicalization), so traversal is structurally +# impossible here. Serves index.html when present; issues a 301 to add a +# trailing slash when a directory is requested without one. +serve_dir_or_file_from() { + local docroot=$1 + local reqpath=${REQUEST_PATH:-/} + local target="${docroot%/}$reqpath" + + if [[ -d $target ]]; then + if [[ $reqpath != */ ]]; then + local loc="${reqpath}/" + [[ -n $QUERY_STRING ]] && loc+="?$QUERY_STRING" + redirect_to "$loc" 301 + return + fi + if [[ -f "${target%/}/index.html" && -r "${target%/}/index.html" ]]; then + serve_file "${target%/}/index.html" + return + fi + if [[ -r $target && -x $target ]]; then + serve_dir "$target" + else + res_send_error 403 + fi + return + fi + + if [[ -e $target ]]; then + if [[ -f $target && -r $target ]]; then + serve_file "$target" + else + res_send_error 403 + fi + return + fi + + res_send_error 404 +} + +# serve_static_string STRING -- serve a literal string as text/plain. +serve_static_string() { + res_add_header 'Content-Type' 'text/plain; charset=utf-8' + res_send 200 -s "$1"$'\n' +} + +# redirect_to URL [CODE] -- issue a redirect (default 302) to URL. +redirect_to() { + local url=$1 code=${2:-302} body esc + esc=$(html_escape "$url") + printf -v body ' + +Redirecting to %s
+ +' "$code" "$(http_status_text "$code")" "$esc" "$esc" + res_add_header 'Location' "$url" + res_add_header 'Content-Type' 'text/html; charset=utf-8' + res_send "$code" -s "$body" +} + +# --------------------------------------------------------------------------- +# Legacy compatibility shims. These keep old bashttpd.conf files working. +# Crucially, none of them exit the process (the old versions did) -- they set +# REQUEST_HANDLED and return so the keep-alive loop can continue. +# --------------------------------------------------------------------------- + +# add_response_header NAME VALUE -- legacy alias for res_add_header. +add_response_header() { + res_add_header "$1" "$2" +} + +# fail_with CODE -- legacy alias that sends an error page for CODE. +fail_with() { + res_send_error "$1" +} + +# send_response_ok_exit -- legacy: send a 200 with the body read from stdin. +# Content-Type defaults to text/plain unless the caller already queued one. +send_response_ok_exit() { + local body has_ct=0 h + for h in "${RES_HEADERS[@]}"; do + [[ ${h,,} == content-type:* ]] && has_ct=1 + done + (( has_ct )) || res_add_header 'Content-Type' 'text/plain; charset=utf-8' + body=$(cat) + res_send 200 -s "$body"$'\n' +} diff --git a/lib/bashttpd/http.sh b/lib/bashttpd/http.sh new file mode 100644 index 0000000..8481dc3 --- /dev/null +++ b/lib/bashttpd/http.sh @@ -0,0 +1,272 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 +# (This module communicates with server.sh and handlers via well-known +# globals -- PARSE_STATUS, REQUEST_*, HEADER_*, RESPONSE_* -- whose +# cross-file consumers are invisible to static analysis.) +# lib/bashttpd/http.sh +# +# Pure HTTP protocol helpers: status text, URL decoding, path canonicalization, +# request-line / header parsing, and response building. Nothing here touches +# the network directly except res_send, which writes to stdout (the socket). +# All functions are self-contained so this file can be sourced standalone and +# unit-tested. + +# http_status_text CODE -> reason phrase on stdout. +http_status_text() { + case $1 in + 200) printf 'OK' ;; + 204) printf 'No Content' ;; + 301) printf 'Moved Permanently' ;; + 302) printf 'Found' ;; + 304) printf 'Not Modified' ;; + 400) printf 'Bad Request' ;; + 403) printf 'Forbidden' ;; + 404) printf 'Not Found' ;; + 405) printf 'Method Not Allowed' ;; + 408) printf 'Request Timeout' ;; + 411) printf 'Length Required' ;; + 413) printf 'Payload Too Large' ;; + 414) printf 'URI Too Long' ;; + 431) printf 'Request Header Fields Too Large' ;; + 500) printf 'Internal Server Error' ;; + 501) printf 'Not Implemented' ;; + 503) printf 'Service Unavailable' ;; + 505) printf 'HTTP Version Not Supported' ;; + *) printf 'Unknown' ;; + esac +} + +# http_date -> current time as an RFC 7231 IMF-fixdate (always GMT/UTC). +http_date() { + LC_ALL=C TZ=UTC date '+%a, %d %b %Y %H:%M:%S GMT' +} + +# http_urldecode STRING -> percent-decoded string on stdout. +# '+' is NOT treated as space (that only applies to query components, not the +# path). Invalid percent escapes are passed through literally. +http_urldecode() { + local str=$1 out='' i=0 c h n + n=${#str} + while (( i < n )); do + c=${str:i:1} + if [[ $c == '%' && $((i + 2)) -lt $n ]]; then + h=${str:i+1:2} + if [[ $h == [0-9A-Fa-f][0-9A-Fa-f] ]]; then + printf -v c '%b' "\\x$h" + out+=$c + (( i += 3 )) + continue + fi + fi + out+=$c + (( i++ )) + done + printf '%s' "$out" +} + +# http_canonicalize_path TARGET -> canonical, decoded path on stdout. +# Strips the query string, percent-decodes, normalizes '.'/'//', resolves '..' +# lexically, and rejects any attempt to escape the root (returns 1). A trailing +# slash on the request is preserved so callers can implement directory +# redirects. Encoded NUL and newline are rejected. +http_canonicalize_path() { + local target=$1 path decoded + path=${target%%\?*} + + # Reject encoded control characters that could confuse path handling. + [[ $path == *%00* ]] && return 1 + + decoded=$(http_urldecode "$path") + + # A literal NUL cannot survive in a bash variable; a newline (%0a) would + # break line-oriented parsing. Reject the latter explicitly. + [[ $decoded == *$'\n'* || $decoded == *$'\r'* ]] && return 1 + + # Ensure an absolute path to begin with. + [[ $decoded == /* ]] || decoded="/$decoded" + + local trailing='' + [[ $decoded == */ && $decoded != '/' ]] && trailing='/' + + local -a out=() segs=() + local seg IFS='/' + read -ra segs <<< "$decoded" + + for seg in "${segs[@]}"; do + case $seg in + ''|.) continue ;; + ..) + (( ${#out[@]} == 0 )) && return 1 # escape above root + unset 'out[-1]' + ;; + *) out+=("$seg") ;; + esac + done + + local result + if (( ${#out[@]} == 0 )); then + result='/' + else + result="/${out[*]}" # IFS=/ joins the segments + [[ -n $trailing ]] && result+='/' + fi + + printf '%s' "$result" +} + +# http_parse_request_line LINE +# On success sets globals REQUEST_METHOD, REQUEST_URI (raw target), and +# REQUEST_HTTP_VERSION, and returns 0. On failure sets PARSE_STATUS to the +# HTTP status the caller should return (400 malformed, 505 bad version) and +# returns 1. +http_parse_request_line() { + local line=$1 method target version + read -r method target version <<< "$line" + + if [[ -z $method || -z $target ]]; then + PARSE_STATUS=400 + return 1 + fi + + # Method must be a valid token (uppercase letters cover every method we + # care about; be strict rather than permissive). + if [[ ! $method =~ ^[A-Za-z]+$ ]]; then + PARSE_STATUS=400 + return 1 + fi + + # No version token at all: HTTP/0.9-style request, which we don't support. + if [[ -z $version ]]; then + PARSE_STATUS=505 + return 1 + fi + + if [[ $version != 'HTTP/1.0' && $version != 'HTTP/1.1' ]]; then + PARSE_STATUS=505 + return 1 + fi + + REQUEST_METHOD=$method + REQUEST_URI=$target + REQUEST_HTTP_VERSION=$version + return 0 +} + +# http_parse_header_line LINE +# On success sets HEADER_KEY (lowercased) and HEADER_VALUE (OWS-trimmed) and +# returns 0. Returns 1 for a malformed header line. +http_parse_header_line() { + local line=$1 key val + [[ $line == *:* ]] || return 1 + + key=${line%%:*} + val=${line#*:} + + # Field name must be non-empty and contain no whitespace (obs-fold and + # bogus continuation lines are rejected). + [[ -z $key ]] && return 1 + [[ $key == *[[:space:]]* ]] && return 1 + + # Trim leading and trailing optional whitespace from the value. + val=${val#"${val%%[![:space:]]*}"} + val=${val%"${val##*[![:space:]]}"} + + HEADER_KEY=${key,,} + HEADER_VALUE=$val + return 0 +} + +# --------------------------------------------------------------------------- +# Response building. res_init resets per-request state; res_add_header queues a +# header; res_send emits the full response to stdout. +# +# The connection disposition (RESPONSE_CONNECTION) and request method +# (REQUEST_METHOD) are read as globals: HEAD suppresses the body but keeps +# identical headers. +# --------------------------------------------------------------------------- + +res_init() { + RES_HEADERS=() + RESPONSE_STATUS=0 + RESPONSE_BYTES=0 + REQUEST_HANDLED='' +} + +res_add_header() { + RES_HEADERS+=("$1: $2") +} + +# res_byte_length VAR_NAME STRING -- store the byte length of STRING in VAR_NAME. +# Uses the C locale so multibyte characters are counted as bytes, matching what +# actually goes on the wire. +res_byte_length() { + local LC_ALL=C + printf -v "$1" '%s' "${#2}" +} + +# res_send CODE [-s STRING | -f FILE] +# Writes the status line, queued headers, the mandatory Content-Length, the +# standard Date/Server/Connection headers, and (unless HEAD) the body. +res_send() { + local code=$1 + shift + local mode='empty' src='' len=0 + + case ${1:-} in + -s) mode='string'; src=$2 ;; + -f) mode='file'; src=$2 ;; + esac + + if [[ $mode == 'string' ]]; then + res_byte_length len "$src" + elif [[ $mode == 'file' ]]; then + len=$(stat -c '%s' -- "$src" 2>/dev/null) || len=0 + fi + + # Status line + queued headers. + printf 'HTTP/1.1 %s %s\r\n' "$code" "$(http_status_text "$code")" + local h + for h in "${RES_HEADERS[@]}"; do + printf '%s\r\n' "$h" + done + + # Standard headers always present. + printf 'Content-Length: %s\r\n' "$len" + printf 'Date: %s\r\n' "$(http_date)" + printf 'Server: %s\r\n' "$SERVER_TOKEN" + printf 'Connection: %s\r\n' "${RESPONSE_CONNECTION:-close}" + printf '\r\n' + + # Body (skipped for HEAD, which must otherwise be identical to GET). + if [[ ${REQUEST_METHOD:-GET} != 'HEAD' ]]; then + case $mode in + string) printf '%s' "$src" ;; + file) cat -- "$src" ;; + esac + fi + + RESPONSE_STATUS=$code + RESPONSE_BYTES=$len + REQUEST_HANDLED=1 + return 0 +} + +# res_send_error CODE -- emit a small HTML error page with the given status. +# Any headers already queued (e.g. Allow for a 405) are preserved. +res_send_error() { + local code=$1 text body + text=$(http_status_text "$code") + printf -v body ' + +%s
+ + +' "$code" "$text" "$code" "$text" "$SERVER_TOKEN" + + res_add_header 'Content-Type' 'text/html; charset=utf-8' + res_send "$code" -s "$body" +} diff --git a/lib/bashttpd/mime.sh b/lib/bashttpd/mime.sh new file mode 100644 index 0000000..825fbc4 --- /dev/null +++ b/lib/bashttpd/mime.sh @@ -0,0 +1,59 @@ +# shellcheck shell=bash +# lib/bashttpd/mime.sh +# +# MIME type resolution. A static extension table handles the common cases +# without spawning a process; anything unknown falls back to file(1) and, as a +# last resort, application/octet-stream. text/* types are tagged utf-8. + +# mime_type_for FILE -> Content-Type value on stdout. +mime_type_for() { + local file=$1 ext type + ext=${file##*.} + ext=${ext,,} + + case $ext in + html|htm) type='text/html' ;; + css) type='text/css' ;; + js|mjs) type='text/javascript' ;; + json) type='application/json' ;; + xml) type='application/xml' ;; + txt|text) type='text/plain' ;; + md|markdown) type='text/markdown' ;; + csv) type='text/csv' ;; + svg) type='image/svg+xml' ;; + png) type='image/png' ;; + jpg|jpeg) type='image/jpeg' ;; + gif) type='image/gif' ;; + webp) type='image/webp' ;; + ico) type='image/x-icon' ;; + avif) type='image/avif' ;; + woff) type='font/woff' ;; + woff2) type='font/woff2' ;; + ttf) type='font/ttf' ;; + otf) type='font/otf' ;; + pdf) type='application/pdf' ;; + zip) type='application/zip' ;; + gz|tgz) type='application/gzip' ;; + tar) type='application/x-tar' ;; + mp3) type='audio/mpeg' ;; + mp4) type='video/mp4' ;; + webm) type='video/webm' ;; + wasm) type='application/wasm' ;; + sh) type='text/x-shellscript' ;; + *) type='' ;; + esac + + if [[ -z $type ]]; then + # Unknown extension: ask file(1) if available, else generic binary. + if command -v file >/dev/null 2>&1; then + type=$(file -b --mime-type -- "$file" 2>/dev/null) + fi + [[ -z $type ]] && type='application/octet-stream' + fi + + # Tag text types with a charset so browsers render them correctly. + case $type in + text/*) printf '%s; charset=utf-8' "$type" ;; + *) printf '%s' "$type" ;; + esac +} diff --git a/lib/bashttpd/router.sh b/lib/bashttpd/router.sh new file mode 100644 index 0000000..d86fe6d --- /dev/null +++ b/lib/bashttpd/router.sh @@ -0,0 +1,46 @@ +# shellcheck shell=bash +# lib/bashttpd/router.sh +# +# The configuration DSL. A bashttpd.conf is a bash script sourced once per +# request; these directives are what it calls to route requests. The first +# directive that matches handles the request and marks it done (REQUEST_HANDLED), +# so later directives short-circuit -- mirroring the legacy "first match wins" +# behavior, but without exiting the process. + +# on_uri_match REGEX COMMAND [ARGS...] +# If the decoded request path matches REGEX, invoke COMMAND with ARGS followed +# by the capture groups (${BASH_REMATCH[@]}), exactly like the legacy version -- +# except matching is done against the canonical decoded path, not the raw URI. +on_uri_match() { + [[ -n $REQUEST_HANDLED ]] && return 0 + local regex=$1 + shift + if [[ $REQUEST_PATH =~ $regex ]]; then + "$@" "${BASH_REMATCH[@]}" + REQUEST_HANDLED=1 + fi + return 0 +} + +# on_method_match METHOD COMMAND [ARGS...] +# Dispatch when the request method equals METHOD (case-insensitive). +on_method_match() { + [[ -n $REQUEST_HANDLED ]] && return 0 + local method=${1^^} + shift + if [[ ${REQUEST_METHOD^^} == "$method" ]]; then + "$@" "$REQUEST_PATH" + REQUEST_HANDLED=1 + fi + return 0 +} + +# unconditionally COMMAND [ARGS...] +# Catch-all: always dispatch (unless already handled). The decoded path is +# appended as the final argument, matching the legacy calling convention. +unconditionally() { + [[ -n $REQUEST_HANDLED ]] && return 0 + "$@" "$REQUEST_PATH" + REQUEST_HANDLED=1 + return 0 +} diff --git a/lib/bashttpd/server.sh b/lib/bashttpd/server.sh new file mode 100644 index 0000000..256b306 --- /dev/null +++ b/lib/bashttpd/server.sh @@ -0,0 +1,231 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 +# (Sets per-request globals -- HTTP_HOST, REQUEST_PATH, QUERY_STRING, +# REQUEST_BODY -- whose consumers are handlers and user config files +# invisible to static analysis.) +# lib/bashttpd/server.sh +# +# The per-connection request loop. stdin/stdout are one TCP (or TLS) connection. +# Handles request framing, keep-alive, header/body limits, method dispatch, and +# access logging. Each request resets response state, evaluates the user config +# (or a built-in default route), and continues the loop -- handlers never exit +# the process. + +# Tunables (overridable via environment). +: "${BASHTTPD_TIMEOUT:=5}" # idle read timeout, seconds (keep-alive) +: "${BASHTTPD_MAX_REQUESTS:=100}" # max requests per connection +: "${BASHTTPD_MAX_BODY:=1048576}" # max request body bytes (1 MiB) +: "${BASHTTPD_MAX_LINE:=8190}" # max request-line / header-line length +: "${BASHTTPD_MAX_HEADERS:=100}" # max header count + +# server_client_addr -> best-effort client address from the listener's env. +server_client_addr() { + printf '%s' "${SOCAT_PEERADDR:-${NCAT_REMOTE_ADDR:--}}" +} + +# server_log_access -- one access-log line per request, to stderr. +server_log_access() { + local client + client=$(server_client_addr) + printf '%s - "%s %s %s" %s %s\n' \ + "$client" \ + "${REQUEST_METHOD:--}" \ + "${REQUEST_URI:--}" \ + "${REQUEST_HTTP_VERSION:--}" \ + "${RESPONSE_STATUS:-000}" \ + "${RESPONSE_BYTES:-0}" >&2 +} + +# server_dispatch -- route the request. Prefer the user config (sourced fresh +# each request, like the legacy design). With no config, fall back to serving a +# docroot (GET/HEAD only) or a hello-world string. +server_dispatch() { + if [[ -n ${BASHTTPD_CONFIG:-} && -r ${BASHTTPD_CONFIG:-} ]]; then + # shellcheck source=/dev/null # user-supplied config, unknowable here + source "$BASHTTPD_CONFIG" + return + fi + + # Built-in default only serves safe, read-only methods. + [[ $REQUEST_METHOD == 'GET' || $REQUEST_METHOD == 'HEAD' ]] || return + + if [[ -n ${BASHTTPD_DOCROOT:-} ]]; then + serve_dir_or_file_from "$BASHTTPD_DOCROOT" + else + serve_static_string 'Hello, world! bashttpd is running. Configure it with a bashttpd.conf or -d DOCROOT.' + fi +} + +# server_send_simple_error CODE -- reset state, force close, send error page. +# Used for protocol-level failures where continuing the connection is unsafe. +server_send_simple_error() { + RESPONSE_CONNECTION='close' + res_init + res_send_error "$1" + server_log_access +} + +# server_handle_connection -- the main loop. Returns when the connection should +# close (timeout, EOF, error, Connection: close, or request cap reached). +server_handle_connection() { + local req_count=0 line + + while (( req_count < BASHTTPD_MAX_REQUESTS )); do + # --- Request line (with idle keep-alive timeout) -------------------- + if ! IFS= read -r -t "$BASHTTPD_TIMEOUT" line; then + return 0 # timeout or client closed the connection + fi + line=${line%$'\r'} + + # Tolerate a single leading blank line (some clients emit CRLF before + # a request); don't count it against the request cap. + if [[ -z $line ]]; then + continue + fi + + (( req_count++ )) + + if (( ${#line} > BASHTTPD_MAX_LINE )); then + server_send_simple_error 414 + return 0 + fi + + # Reset per-request globals used by handlers/logging. + REQUEST_METHOD='' REQUEST_URI='' REQUEST_HTTP_VERSION='' + REQUEST_PATH='' QUERY_STRING='' HTTP_HOST='' REQUEST_BODY='' + + if ! http_parse_request_line "$line"; then + server_send_simple_error "${PARSE_STATUS:-400}" + return 0 + fi + + # --- Headers -------------------------------------------------------- + local -A HEADERS=() + local header_count=0 bad_headers=0 + while IFS= read -r -t "$BASHTTPD_TIMEOUT" line; do + line=${line%$'\r'} + [[ -z $line ]] && break + + if (( ${#line} > BASHTTPD_MAX_LINE )); then + server_send_simple_error 431 + return 0 + fi + + (( header_count++ )) + if (( header_count > BASHTTPD_MAX_HEADERS )); then + server_send_simple_error 431 + return 0 + fi + + if http_parse_header_line "$line"; then + HEADERS[$HEADER_KEY]=$HEADER_VALUE + else + bad_headers=1 + fi + done || return 0 # EOF/timeout mid-headers: drop the connection + + if (( bad_headers )); then + server_send_simple_error 400 + return 0 + fi + + HTTP_HOST=${HEADERS[host]:-} + + # HTTP/1.1 requires a Host header. + if [[ $REQUEST_HTTP_VERSION == 'HTTP/1.1' && -z ${HEADERS[host]+set} ]]; then + server_send_simple_error 400 + return 0 + fi + + # --- Keep-alive decision -------------------------------------------- + local conn=${HEADERS[connection]:-} + conn=${conn,,} + local keepalive + if [[ $REQUEST_HTTP_VERSION == 'HTTP/1.1' ]]; then + keepalive=1 + [[ $conn == *close* ]] && keepalive=0 + else + keepalive=0 + [[ $conn == *keep-alive* ]] && keepalive=1 + fi + + # --- Request body --------------------------------------------------- + if [[ -n ${HEADERS[transfer-encoding]+set} ]]; then + # We do not implement chunked/other transfer codings. + RESPONSE_CONNECTION='close' + res_init + res_send_error 501 + server_log_access + return 0 + fi + + local clen=${HEADERS[content-length]:-0} + if [[ -n $clen && ! $clen =~ ^[0-9]+$ ]]; then + server_send_simple_error 400 + return 0 + fi + [[ -z $clen ]] && clen=0 + + if (( clen > BASHTTPD_MAX_BODY )); then + RESPONSE_CONNECTION='close' + res_init + res_send_error 413 + server_log_access + return 0 + fi + + if (( clen > 0 )); then + # Drain exactly clen bytes so keep-alive framing survives, even if + # the body is ignored by the handler. + IFS= read -r -N "$clen" -t "$BASHTTPD_TIMEOUT" REQUEST_BODY + fi + + # --- Compute path / query, set the connection disposition ----------- + if ! REQUEST_PATH=$(http_canonicalize_path "$REQUEST_URI"); then + server_send_simple_error 400 + return 0 + fi + QUERY_STRING='' + [[ $REQUEST_URI == *\?* ]] && QUERY_STRING=${REQUEST_URI#*\?} + + if (( keepalive )) && (( req_count < BASHTTPD_MAX_REQUESTS )); then + RESPONSE_CONNECTION='keep-alive' + else + RESPONSE_CONNECTION='close' + fi + + res_init + + # --- Method handling ------------------------------------------------ + case $REQUEST_METHOD in + OPTIONS) + # OPTIONS * or OPTIONS /path -> 204 with Allow. + res_add_header 'Allow' 'GET, HEAD, OPTIONS' + res_send 204 + ;; + GET|HEAD|POST|PUT|DELETE|PATCH) + server_dispatch + if [[ -z $REQUEST_HANDLED ]]; then + if [[ $REQUEST_METHOD == 'GET' || $REQUEST_METHOD == 'HEAD' ]]; then + res_send_error 404 + else + res_add_header 'Allow' 'GET, HEAD, OPTIONS' + res_send_error 405 + fi + fi + ;; + *) + res_send_error 501 + ;; + esac + + server_log_access + + # --- Loop or close -------------------------------------------------- + if [[ $RESPONSE_CONNECTION != 'keep-alive' ]]; then + return 0 + fi + done + + return 0 +} diff --git a/scripts/build-single.sh b/scripts/build-single.sh new file mode 100755 index 0000000..f8808a8 --- /dev/null +++ b/scripts/build-single.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# +# scripts/build-single.sh -- build a fully self-contained single-file bashttpd. +# +# The modular tree (bin/bashttpd + lib/bashttpd/*.sh) is convenient to hack on +# but awkward to distribute. This script inlines the library modules into the +# CLI entrypoint to produce dist/bashttpd: one file that behaves identically to +# bin/bashttpd for every subcommand, needs neither lib/ nor BASHTTPD_LIB_DIR at +# runtime, and can be dropped anywhere on its own (e.g. curled from a release). +# +# How the inlining works +# ---------------------- +# bin/bashttpd wraps its library-location + load_lib block in a pair of marker +# comments: +# +# # @@INLINE_LIBS_BEGIN@@ +# ... locate REPO_DIR/LIB_DIR, define load_lib() { source each module } ... +# # @@INLINE_LIBS_END@@ +# +# The build emits, in order: +# 1. a single `#!/usr/bin/env bash` shebang, +# 2. a generated-file header (version + source revision + build date), +# 3. every lib module concatenated in dependency order (core, http, mime, +# router, handlers, server), with only the redundant per-file +# `# shellcheck shell=bash` directive stripped, and +# 4. bin/bashttpd's own body, with its shebang dropped and the marked block +# replaced by a stub that keeps SELF/REPO_DIR/LIB_DIR defined (still used +# by other subcommands) and turns load_lib into a no-op, since the modules +# are already present above. +# +# The result is shellcheck-clean under a single shebang. + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd) + +SRC_BIN="$REPO_ROOT/bin/bashttpd" +SRC_LIB_DIR="$REPO_ROOT/lib/bashttpd" +OUT_DIR="$REPO_ROOT/dist" +OUT="$OUT_DIR/bashttpd" + +# Dependency order: core first (constants/logging), server last (request loop). +MODULES=(core http mime router handlers server) + +BEGIN_MARKER='# @@INLINE_LIBS_BEGIN@@' +END_MARKER='# @@INLINE_LIBS_END@@' + +# --- Sanity checks ----------------------------------------------------------- +[[ -r $SRC_BIN ]] || { printf 'build-single: missing %s\n' "$SRC_BIN" >&2; exit 1; } +for m in "${MODULES[@]}"; do + [[ -r "$SRC_LIB_DIR/$m.sh" ]] || { + printf 'build-single: missing %s\n' "$SRC_LIB_DIR/$m.sh" >&2; exit 1 + } +done +if ! grep -qF "$BEGIN_MARKER" "$SRC_BIN" || ! grep -qF "$END_MARKER" "$SRC_BIN"; then + printf 'build-single: %s is missing the @@INLINE_LIBS@@ markers\n' "$SRC_BIN" >&2 + exit 1 +fi + +# --- Gather build metadata --------------------------------------------------- +version=$(sed -n "s/^BASHTTPD_VERSION='\\(.*\\)'.*/\\1/p" "$SRC_LIB_DIR/core.sh" | head -1) +[[ -n $version ]] || version='unknown' + +# Source revision: nice to have, but the build must work outside a git checkout. +revision='unknown' +if command -v git >/dev/null 2>&1 && + git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then + revision=$(git -C "$REPO_ROOT" describe --tags --always --dirty 2>/dev/null) || revision='' + [[ -n $revision ]] || revision=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null) || revision='unknown' +fi + +# Reproducible-ish build date; SOURCE_DATE_EPOCH honored when set. +if [[ -n ${SOURCE_DATE_EPOCH:-} ]]; then + build_date=$(date -u -d "@$SOURCE_DATE_EPOCH" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || + date -u -r "$SOURCE_DATE_EPOCH" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo unknown) +else + build_date=$(date -u '+%Y-%m-%dT%H:%M:%SZ') +fi + +# --- Replacement for the marked lib-loading block ---------------------------- +read -r -d '' INLINE_STUB <<'STUB' || true +# --- Locate ourselves (single-file build; libraries are inlined above) ------ +# This is a generated, self-contained build. SELF/REPO_DIR/LIB_DIR are kept +# because other subcommands still reference them, but no lib/ directory or +# BASHTTPD_LIB_DIR environment variable is required at runtime. +SELF=$(readlink -f "${BASH_SOURCE[0]}") +BIN_DIR=${SELF%/*} +REPO_DIR=${BIN_DIR%/*} +LIB_DIR=$BIN_DIR/lib/bashttpd + +# The library modules are inlined at the top of this file, so loading them is +# a no-op here. +load_lib() { :; } +STUB + +# --- Emit the single file ---------------------------------------------------- +mkdir -p "$OUT_DIR" + +{ + printf '#!/usr/bin/env bash\n' + printf '#\n' + printf '# bashttpd %s -- single-file build. GENERATED by scripts/build-single.sh.\n' "$version" + printf '# DO NOT EDIT: change bin/bashttpd or lib/bashttpd/*.sh and rebuild with: make build\n' + printf '#\n' + printf '# source revision: %s\n' "$revision" + printf '# built (UTC): %s\n' "$build_date" + printf '#\n' + # File-wide: the modules share state through well-known globals whose + # cross-function producers/consumers are invisible to static analysis. + printf '# shellcheck disable=SC2034\n' + printf '#\n' + + for m in "${MODULES[@]}"; do + printf '# ===================== lib/bashttpd/%s.sh =====================\n' "$m" + grep -v '^# shellcheck shell=bash$' "$SRC_LIB_DIR/$m.sh" + printf '\n' + done + + printf '# ===================== bin/bashttpd =====================\n' + # Drop the shebang (first line) and swap the marked block for the stub. + awk -v begin="$BEGIN_MARKER" -v end="$END_MARKER" -v stub="$INLINE_STUB" ' + NR == 1 && /^#!/ { next } # drop the original shebang + $0 == begin { print stub; skip = 1; next } + $0 == end { skip = 0; next } + # The version subcommand references lib/bashttpd/core.sh via a shellcheck + # source= hint; that path is meaningless once the module is inlined, so + # point the directive at /dev/null to stay lint-clean (the guarded + # source is harmless dead code here -- the constant is already set). + $0 == " # shellcheck source=lib/bashttpd/core.sh" { + print " # shellcheck source=/dev/null"; next + } + !skip + ' "$SRC_BIN" +} > "$OUT" + +chmod +x "$OUT" + +printf 'build-single: wrote %s (bashttpd %s, %s)\n' "$OUT" "$version" "$revision" >&2 diff --git a/scripts/gen-cert.sh b/scripts/gen-cert.sh new file mode 100755 index 0000000..4477c2e --- /dev/null +++ b/scripts/gen-cert.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# scripts/gen-cert.sh -- generate a self-signed cert/key for bashttpd --tls. +# +# Usage: scripts/gen-cert.sh [CERT_OUT] [KEY_OUT] +# Defaults: certs/server.crt and certs/server.key +# +# The certificate is a self-signed EC cert for CN=localhost with SANs for +# localhost and 127.0.0.1, valid for 365 days. The key is written mode 600. + +set -euo pipefail + +CERT_OUT=${1:-certs/server.crt} +KEY_OUT=${2:-certs/server.key} + +command -v openssl >/dev/null 2>&1 || { + printf 'gen-cert: openssl is required but not found\n' >&2 + exit 1 +} + +mkdir -p -- "$(dirname -- "$CERT_OUT")" "$(dirname -- "$KEY_OUT")" + +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ + -nodes \ + -keyout "$KEY_OUT" \ + -out "$CERT_OUT" \ + -days 365 \ + -subj '/CN=localhost' \ + -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' 2>/dev/null + +chmod 600 -- "$KEY_OUT" + +printf 'Wrote certificate: %s\n' "$CERT_OUT" +printf 'Wrote private key: %s (mode 600)\n' "$KEY_OUT" +printf '\nStart with TLS:\n bin/bashttpd --tls --cert %s --key %s\n' "$CERT_OUT" "$KEY_OUT" diff --git a/tests/integration/cli.bats b/tests/integration/cli.bats new file mode 100644 index 0000000..a9334d2 --- /dev/null +++ b/tests/integration/cli.bats @@ -0,0 +1,97 @@ +#!/usr/bin/env bats +# Integration tests for the bin/bashttpd CLI subcommands (no live server +# needed for most of these). + +setup() { + load '../test_helper' +} + +@test "--version prints the version" { + run "$BASHTTPD_BIN" --version + [[ $status -eq 0 ]] + [[ $output =~ ^bashttpd\ [0-9]+\.[0-9]+ ]] +} + +@test "version subcommand prints the version" { + run "$BASHTTPD_BIN" version + [[ $status -eq 0 ]] + [[ $output =~ ^bashttpd\ [0-9]+\.[0-9]+ ]] +} + +@test "--help mentions serve and handle" { + run "$BASHTTPD_BIN" --help + [[ $status -eq 0 ]] + [[ $output == *'serve'* ]] + [[ $output == *'handle'* ]] +} + +@test "help subcommand mentions serve and handle" { + run "$BASHTTPD_BIN" help + [[ $status -eq 0 ]] + [[ $output == *'serve'* ]] + [[ $output == *'handle'* ]] +} + +@test "init-config writes an example config file" { + cd "$BATS_TEST_TMPDIR" + run "$BASHTTPD_BIN" init-config myconf.conf + [[ $status -eq 0 ]] + [[ -f myconf.conf ]] + grep -q 'on_uri_match' myconf.conf +} + +@test "init-config refuses to overwrite an existing file" { + cd "$BATS_TEST_TMPDIR" + printf 'sentinel-content\n' > existing.conf + run "$BASHTTPD_BIN" init-config existing.conf + [[ $status -ne 0 ]] + # The original file must be untouched. + run cat existing.conf + [[ $output == 'sentinel-content' ]] +} + +@test "init-config with default destination writes ./bashttpd.conf" { + cd "$BATS_TEST_TMPDIR" + run "$BASHTTPD_BIN" init-config + [[ $status -eq 0 ]] + [[ -f bashttpd.conf ]] +} + +@test "--tls without --cert/--key fails with a nonzero status" { + run "$BASHTTPD_BIN" --tls + [[ $status -ne 0 ]] + [[ $output == *'--tls requires'* || $output == *'cert'* ]] +} + +@test "unknown option fails with a nonzero status" { + run "$BASHTTPD_BIN" --not-a-real-option + [[ $status -ne 0 ]] +} + +@test "unreadable config file fails with a nonzero status" { + run "$BASHTTPD_BIN" --config "$BATS_TEST_TMPDIR/does-not-exist.conf" -p 0 + [[ $status -ne 0 ]] +} + +@test "bad docroot fails with a nonzero status" { + run "$BASHTTPD_BIN" -d "$BATS_TEST_TMPDIR/no-such-dir" -p 0 + [[ $status -ne 0 ]] +} + +@test "invalid port fails with a nonzero status" { + run "$BASHTTPD_BIN" -p notaport -d "$BATS_TEST_TMPDIR" + [[ $status -ne 0 ]] +} + +@test "root compatibility shim --help works" { + run "$REPO_ROOT/bashttpd" --help + [[ $status -eq 0 ]] + [[ $output == *'serve'* ]] + [[ $output == *'handle'* ]] +} + +@test "root compatibility shim --version works" { + run "$REPO_ROOT/bashttpd" --version + [[ $status -eq 0 ]] + [[ $output =~ ^bashttpd\ [0-9]+\.[0-9]+ ]] +} diff --git a/tests/integration/server.bats b/tests/integration/server.bats new file mode 100644 index 0000000..ac9d6ea --- /dev/null +++ b/tests/integration/server.bats @@ -0,0 +1,239 @@ +#!/usr/bin/env bats +# Integration tests against a live bashttpd server serving a temp docroot. + +setup() { + load '../test_helper' + + DOCROOT="$BATS_TEST_TMPDIR/docroot" + mkdir -p "$DOCROOT/subdir" + + INDEX_BODY='Index page' + printf '%s' "$INDEX_BODY" > "$DOCROOT/index.html" + + printf 'hello from subdir\n' > "$DOCROOT/subdir/hello.txt" + + NOTE_BODY='plain note text' + printf '%s' "$NOTE_BODY" > "$DOCROOT/note.txt" + + head -c 4096 /dev/urandom > "$DOCROOT/blob.bin" + + SPACE_BODY='space file content' + printf '%s' "$SPACE_BODY" > "$DOCROOT/my file.txt" + + UNREADABLE="$DOCROOT/secret.txt" + printf 'top secret' > "$UNREADABLE" + chmod 000 "$UNREADABLE" + + start_server -d "$DOCROOT" +} + +teardown() { + chmod 700 "$UNREADABLE" 2>/dev/null || true + stop_server +} + +# --- basic GET ----------------------------------------------------------- + +@test "GET / returns 200 with correct Content-Type and Content-Length" { + local headers + headers=$(curl -s -D - -o "$BATS_TEST_TMPDIR/body.out" "$SERVER_BASE/") + local code ct clen + code=$(http_status_code "$headers") + ct=$(header_value "$headers" 'Content-Type') + clen=$(header_value "$headers" 'Content-Length') + [[ $code -eq 200 ]] + [[ $ct == 'text/html; charset=utf-8' ]] + [[ $clen -eq ${#INDEX_BODY} ]] + diff <(printf '%s' "$INDEX_BODY") "$BATS_TEST_TMPDIR/body.out" +} + +# --- directory redirect / listing ----------------------------------------- + +@test "GET /subdir (no trailing slash) returns 301 to /subdir/" { + local headers + headers=$(curl -s -D - -o /dev/null "$SERVER_BASE/subdir") + local code loc + code=$(http_status_code "$headers") + loc=$(header_value "$headers" 'Location') + [[ $code -eq 301 ]] + [[ $loc == '/subdir/' ]] +} + +@test "GET /subdir/ lists the directory contents" { + run curl -s "$SERVER_BASE/subdir/" + [[ $status -eq 0 ]] + [[ $output == *'hello.txt'* ]] +} + +# --- 404 / 403 ------------------------------------------------------------- + +@test "GET missing path returns 404" { + run curl -s -o /dev/null -w '%{http_code}' "$SERVER_BASE/does/not/exist" + [[ $output == '404' ]] +} + +@test "GET an unreadable file returns 403 (skipped when running as root)" { + if [[ ${EUID:-$(id -u)} -eq 0 ]]; then + skip "running as root bypasses file permission checks" + fi + run curl -s -o /dev/null -w '%{http_code}' "$SERVER_BASE/secret.txt" + [[ $output == '403' ]] +} + +# --- HEAD -------------------------------------------------------------------- + +@test "HEAD returns 200, no body, and the same Content-Length as GET" { + local get_headers head_headers get_clen head_clen head_body + get_headers=$(curl -s -D - -o /dev/null "$SERVER_BASE/note.txt") + head_headers=$(curl -s -I "$SERVER_BASE/note.txt") + get_clen=$(header_value "$get_headers" 'Content-Length') + head_clen=$(header_value "$head_headers" 'Content-Length') + head_body=$(curl -s --head "$SERVER_BASE/note.txt" | sed '1,/^\r*$/d') + [[ $(http_status_code "$head_headers") -eq 200 ]] + [[ $get_clen -eq $head_clen ]] + [[ $get_clen -eq ${#NOTE_BODY} ]] + [[ -z $head_body ]] +} + +# --- method handling ----------------------------------------------------- + +@test "POST to a GET-only route returns 405 with an Allow header" { + local headers code allow + headers=$(curl -s -D - -o /dev/null -X POST -d 'x=1' "$SERVER_BASE/") + code=$(http_status_code "$headers") + allow=$(header_value "$headers" 'Allow') + [[ $code -eq 405 ]] + [[ $allow == 'GET, HEAD, OPTIONS' ]] +} + +@test "OPTIONS returns 204 with an Allow header" { + local headers code allow + headers=$(curl -s -D - -o /dev/null -X OPTIONS "$SERVER_BASE/") + code=$(http_status_code "$headers") + allow=$(header_value "$headers" 'Allow') + [[ $code -eq 204 ]] + [[ $allow == 'GET, HEAD, OPTIONS' ]] +} + +# --- keep-alive / connection handling -------------------------------------- + +@test "keep-alive: two requests on one curl invocation reuse a single connection" { + # -o /dev/null must be repeated once per URL -- curl's -o is a + # per-transfer option, and with only one instance the second URL's body + # would fall through to stdout instead of being discarded. + run curl -s -o /dev/null -o /dev/null -w '%{num_connects} ' \ + "$SERVER_BASE/note.txt" "$SERVER_BASE/index.html" + [[ $status -eq 0 ]] + local total=0 n + for n in $output; do total=$(( total + n )); done + [[ $total -eq 1 ]] +} + +@test "Connection: close forces a fresh connection for each request" { + run curl -s -o /dev/null -o /dev/null -w '%{num_connects} ' -H 'Connection: close' \ + "$SERVER_BASE/note.txt" "$SERVER_BASE/index.html" + [[ $status -eq 0 ]] + local total=0 n + for n in $output; do total=$(( total + n )); done + [[ $total -eq 2 ]] +} + +# --- percent-encoded paths / binary integrity ------------------------------ + +@test "percent-encoded space in filename resolves correctly" { + local code body + code=$(curl -s -o /dev/null -w '%{http_code}' "$SERVER_BASE/my%20file.txt") + body=$(curl -s "$SERVER_BASE/my%20file.txt") + [[ $code -eq 200 ]] + [[ $body == "$SPACE_BODY" ]] +} + +@test "binary file is transferred byte-identical" { + curl -s "$SERVER_BASE/blob.bin" -o "$BATS_TEST_TMPDIR/blob.out" + cmp -s "$DOCROOT/blob.bin" "$BATS_TEST_TMPDIR/blob.out" +} + +@test "binary file gets an octet-stream Content-Type" { + run curl -s -o /dev/null -D - "$SERVER_BASE/blob.bin" + local ct + ct=$(header_value "$output" 'Content-Type') + [[ $ct == 'application/octet-stream' ]] +} + +# --- path traversal ---------------------------------------------------------- + +@test "path traversal via --path-as-is does not leak files outside the docroot" { + local code + code=$(curl -s --path-as-is -o "$BATS_TEST_TMPDIR/trav.out" -w '%{http_code}' \ + "$SERVER_BASE/../../../../etc/passwd") + [[ $code == 400 || $code == 404 ]] + if [[ -s "$BATS_TEST_TMPDIR/trav.out" ]]; then + run grep -q 'root:' "$BATS_TEST_TMPDIR/trav.out" + [[ $status -ne 0 ]] + fi +} + +# --- raw protocol edge cases (via nc) --------------------------------------- + +@test "a malformed request line returns 400 Bad Request" { + local resp + resp=$(raw_request "$SERVER_PORT" 'GARBAGE\r\n\r\n') + local first + first=$(printf '%s\n' "$resp" | head -1 | tr -d '\r') + [[ $first == 'HTTP/1.1 400 Bad Request' ]] +} + +@test "HTTP/2.0 in the request line returns 505" { + local resp + resp=$(raw_request "$SERVER_PORT" 'GET / HTTP/2.0\r\nHost: x\r\n\r\n') + local first + first=$(printf '%s\n' "$resp" | head -1 | tr -d '\r') + [[ $first == 'HTTP/1.1 505 HTTP Version Not Supported' ]] +} + +@test "an HTTP/0.9-style request line (no version) returns 505" { + local resp + resp=$(raw_request "$SERVER_PORT" 'GET /\r\n\r\n') + local first + first=$(printf '%s\n' "$resp" | head -1 | tr -d '\r') + [[ $first == 'HTTP/1.1 505 HTTP Version Not Supported' ]] +} + +@test "HTTP/1.1 without a Host header returns 400" { + local resp + resp=$(raw_request "$SERVER_PORT" 'GET / HTTP/1.1\r\n\r\n') + local first + first=$(printf '%s\n' "$resp" | head -1 | tr -d '\r') + [[ $first == 'HTTP/1.1 400 Bad Request' ]] +} + +@test "Transfer-Encoding: chunked returns 501 Not Implemented" { + local resp + resp=$(raw_request "$SERVER_PORT" 'POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n') + local first + first=$(printf '%s\n' "$resp" | head -1 | tr -d '\r') + [[ $first == 'HTTP/1.1 501 Not Implemented' ]] +} + +@test "a request body is fully drained so a following request on the same connection still works" { + local body='a=1&b=2' + local clen=${#body} + local reqfile="$BATS_TEST_TMPDIR/two-requests.raw" + + # Build the raw byte stream in a file (redirection preserves exact bytes, + # unlike a $(...) capture which would strip the trailing blank-line CRLF). + { + printf 'POST / HTTP/1.1\r\nHost: x\r\nContent-Length: %d\r\nConnection: keep-alive\r\n\r\n%s' "$clen" "$body" + printf 'GET /note.txt HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' + } > "$reqfile" + + local resp + resp=$(timeout 5 nc -q1 127.0.0.1 "$SERVER_PORT" < "$reqfile") + + local -a statuses + mapfile -t statuses < <(printf '%s\n' "$resp" | tr -d '\r' | grep -o '^HTTP/1\.1 [0-9]\{3\}') + [[ ${#statuses[@]} -eq 2 ]] + [[ ${statuses[0]} == 'HTTP/1.1 405' ]] # POST isn't allowed by the default docroot route + [[ ${statuses[1]} == 'HTTP/1.1 200' ]] + [[ $resp == *"$NOTE_BODY"* ]] +} diff --git a/tests/integration/single_file.bats b/tests/integration/single_file.bats new file mode 100644 index 0000000..f855a18 --- /dev/null +++ b/tests/integration/single_file.bats @@ -0,0 +1,83 @@ +#!/usr/bin/env bats +# Integration tests for the self-contained single-file build (dist/bashttpd). +# +# The build is produced by scripts/build-single.sh, then a *copy* of the single +# file is dropped into an otherwise-empty directory (no lib/, no repo) and +# exercised from there -- proving it needs neither lib/ nor BASHTTPD_LIB_DIR at +# runtime and behaves like bin/bashttpd. Server lifecycle reuses the shared +# start_server/stop_server helpers by pointing BASHTTPD_BIN at the copy. + +setup() { + load '../test_helper' + + # Build the artifact (idempotent; a no-op once dist/ is up to date). + run "$REPO_ROOT/scripts/build-single.sh" + [[ $status -eq 0 ]] + [[ -x "$REPO_ROOT/dist/bashttpd" ]] + + # Copy the single file into an empty dir with nothing else around it. + STANDALONE_DIR="$BATS_TEST_TMPDIR/standalone" + mkdir -p "$STANDALONE_DIR" + cp "$REPO_ROOT/dist/bashttpd" "$STANDALONE_DIR/bashttpd" + chmod +x "$STANDALONE_DIR/bashttpd" + + # Point the shared server helpers at the standalone copy. + BASHTTPD_BIN="$STANDALONE_DIR/bashttpd" + + DOCROOT="$BATS_TEST_TMPDIR/docroot" + mkdir -p "$DOCROOT" + INDEX_BODY='single-file index' + printf '%s' "$INDEX_BODY" > "$DOCROOT/index.html" + NOTE_BODY='plain note text' + printf '%s' "$NOTE_BODY" > "$DOCROOT/note.txt" +} + +teardown() { + stop_server +} + +@test "single-file: build produced an executable dist/bashttpd" { + [[ -x "$REPO_ROOT/dist/bashttpd" ]] + run head -1 "$REPO_ROOT/dist/bashttpd" + [[ $output == '#!/usr/bin/env bash' ]] +} + +@test "single-file: version works from a standalone copy (no lib/, no repo)" { + run "$STANDALONE_DIR/bashttpd" version + [[ $status -eq 0 ]] + [[ $output =~ ^bashttpd\ [0-9]+\.[0-9]+ ]] +} + +@test "single-file: the standalone dir contains only the one file" { + run find "$STANDALONE_DIR" -mindepth 1 + [[ $status -eq 0 ]] + [[ $output == "$STANDALONE_DIR/bashttpd" ]] +} + +@test "single-file: serve from the standalone copy answers 200 / 404 / HEAD" { + start_server -d "$DOCROOT" + + local code + code=$(curl -s -o "$BATS_TEST_TMPDIR/body.out" -w '%{http_code}' "$SERVER_BASE/") + [[ $code -eq 200 ]] + diff <(printf '%s' "$INDEX_BODY") "$BATS_TEST_TMPDIR/body.out" + + code=$(curl -s -o /dev/null -w '%{http_code}' "$SERVER_BASE/does/not/exist") + [[ $code -eq 404 ]] + + local head_headers head_body + head_headers=$(curl -s -I "$SERVER_BASE/note.txt") + [[ $(http_status_code "$head_headers") -eq 200 ]] + [[ $(header_value "$head_headers" 'Content-Length') -eq ${#NOTE_BODY} ]] + head_body=$(curl -s --head "$SERVER_BASE/note.txt" | sed '1,/^\r*$/d') + [[ -z $head_body ]] +} + +@test "single-file: handle mode serves one connection over stdin/stdout (inetd-style)" { + local resp first + resp=$(printf 'GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' \ + | BASHTTPD_DOCROOT="$DOCROOT" "$STANDALONE_DIR/bashttpd" handle) + first=$(printf '%s\n' "$resp" | head -1 | tr -d '\r') + [[ $first == 'HTTP/1.1 200 OK' ]] + [[ $resp == *"$INDEX_BODY"* ]] +} diff --git a/tests/integration/tls.bats b/tests/integration/tls.bats new file mode 100644 index 0000000..a5b5291 --- /dev/null +++ b/tests/integration/tls.bats @@ -0,0 +1,53 @@ +#!/usr/bin/env bats +# Integration tests for TLS support: generate a cert with scripts/gen-cert.sh, +# start the server with --tls, and confirm HTTPS works while plain HTTP to the +# same port does not. + +setup() { + load '../test_helper' + + CERT="$BATS_TEST_TMPDIR/server.crt" + KEY="$BATS_TEST_TMPDIR/server.key" + + run "$REPO_ROOT/scripts/gen-cert.sh" "$CERT" "$KEY" + [[ $status -eq 0 ]] + [[ -f $CERT ]] + [[ -f $KEY ]] + + DOCROOT="$BATS_TEST_TMPDIR/docroot" + mkdir -p "$DOCROOT" + printf 'tls index\n' > "$DOCROOT/index.html" + + start_server --tls --cert "$CERT" --key "$KEY" -d "$DOCROOT" +} + +teardown() { + stop_server +} + +@test "gen-cert.sh writes a private key with restrictive permissions" { + local perms + perms=$(stat -c '%a' "$KEY") + [[ $perms == 600 ]] +} + +@test "HTTPS request to the TLS listener succeeds" { + run curl -sk -o /dev/null -w '%{http_code}' "$SERVER_BASE/" + [[ $status -eq 0 ]] + [[ $output == '200' ]] +} + +@test "HTTPS response body is served correctly over TLS" { + run curl -sk "$SERVER_BASE/" + [[ $status -eq 0 ]] + [[ $output == 'tls index' ]] +} + +@test "a plain HTTP request to the TLS port does not get a valid HTTP response" { + local plain_url="http://127.0.0.1:$SERVER_PORT/" + run curl -s --max-time 3 -o /dev/null -w '%{http_code}' "$plain_url" + # Either curl fails outright (connection reset/handshake garbage) or the + # response is not a normal 200 -- either way, it must not succeed as if + # it were a plain HTTP request. + [[ $status -ne 0 || $output != '200' ]] +} diff --git a/tests/run b/tests/run new file mode 100755 index 0000000..8f0b062 --- /dev/null +++ b/tests/run @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# tests/run -- run the full bashttpd test suite (unit + integration) and +# propagate the exit code, so this can be used both interactively and in CI. +set -u + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +exec bats --recursive "$DIR" diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..8c0bf71 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,158 @@ +# tests/test_helper.bash +# +# Shared helpers for the bats test suite: repo-root resolution, sourcing pure +# lib modules, starting/stopping a live bashttpd server on a private port, and +# small assertion helpers used by both unit and integration tests. +# +# All helpers are careful to avoid text-pattern process matching (pkill -f) +# since the search pattern can match the invoking shell's own command line +# and kill the wrong thing. Instead we track exact PIDs: bin/bashttpd execs +# straight into socat (no intermediate fork), so `bin/bashttpd serve ... &` +# followed by capturing $! gives the *exact* PID of the listener process. + +# --- Repo root --------------------------------------------------------------- +# BATS_TEST_DIRNAME is tests/unit or tests/integration; the repo root is one +# level above tests/. +repo_root() { + local dir="${BATS_TEST_DIRNAME:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" + ( cd "$dir/../.." && pwd ) +} + +REPO_ROOT="$(repo_root)" +BASHTTPD_BIN="$REPO_ROOT/bin/bashttpd" +LIB_DIR="$REPO_ROOT/lib/bashttpd" + +# source_lib MODULE... -- source one or more pure lib/bashttpd/*.sh modules +# directly into the current shell, for unit testing without a live server. +source_lib() { + local m + for m in "$@"; do + # shellcheck source=/dev/null + source "$LIB_DIR/$m.sh" + done +} + +# --- Free port discovery ------------------------------------------------------ +# _port_in_use PORT -- succeeds (0) if something is already listening on +# 127.0.0.1:PORT, fails otherwise. Uses bash's /dev/tcp so no extra tools are +# needed. +_port_in_use() { + (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null + local rc=$? + exec 3<&- 2>/dev/null + exec 3>&- 2>/dev/null + return $rc +} + +# pick_free_port -- print a free TCP port on stdout. Tries random ports in the +# high range derived from $RANDOM until one looks free. +pick_free_port() { + local port tries + for (( tries = 0; tries < 100; tries++ )); do + port=$(( 20000 + (RANDOM % 25000) )) + if ! _port_in_use "$port"; then + printf '%s' "$port" + return 0 + fi + done + return 1 +} + +# --- Server lifecycle --------------------------------------------------------- +# start_server [bashttpd-serve-args...] +# Starts `bin/bashttpd serve` on 127.0.0.1: