Commit Diff


commit - /dev/null
commit + 69e5688156aca3acbec8c121d025a9fab2752058
blob - /dev/null
blob + 1c363d2841cdff3fecddc69509795cad4461aabf (mode 644)
--- /dev/null
+++ LICENCE
@@ -0,0 +1,14 @@
+BSD Zero Clause License
+
+Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
blob - /dev/null
blob + b4fae62430f05b3185b8d0997ac89418fdecbcf2 (mode 644)
--- /dev/null
+++ README.md
@@ -0,0 +1,13 @@
+A Web Server
+============
+
+This was a joke to be able to say my website ran on `aws`.
+
+Don't sue me.
+
+Also, don't use this. Use [Caddy](https://caddyserver.com/) instead,
+unless you fit the very specific requirements met by this.
+
+It's overly "secure" and will only really work on static sites.
+
+For more information check the man page.
\ No newline at end of file
blob - /dev/null
blob + 5facc20491a58c5de2abcfcc3ff24604eb3a7efe (mode 644)
--- /dev/null
+++ aws.1
@@ -0,0 +1,95 @@
+.\" Copyright (C) 2020 by Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+.\"
+.\" Permission to use, copy, modify, and/or distribute this software for any purpose
+.\" with or without fee is hereby granted.
+.\"
+.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+.\" REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+.\" AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+.\" INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+.\" LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
+.\" OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+.\" PERFORMANCE OF THIS SOFTWARE.
+.Dd July 2, 2020
+.Dt AWS 1
+.Os
+.Sh NAME
+.Nm aws
+.Nd simple secure (-ish) static webserver
+.Sh SYNOPSIS
+.Nm
+.Op Fl c Pa directory
+.Ar hostname ...
+.Sh DESCRIPTION
+.Nm
+serves the files and subdirectories of the directory from which it is run.
+.Pp
+TLS certificates will be automatically sourced from
+.Lk https://letsencrypt.org/ "Let's Encrypt"
+for all hostnames specified when
+.Nm
+is called.
+.Pp
+.Nm
+sets the following HTTP headers for all responses:
+.Bd -literal
+"Content-Security-Policy": "default-src 'none'; style-src 'self'; img-src 'self'; object-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; plugin-types application/pdf"
+"Referrer-Policy": "no-referrer"
+"Strict-Transport-Security": "max-age=63072000; includeSubDomains"
+"X-Content-Type-Options": "nosniff"
+"X-Frame-Options": "DENY"
+"X-XSS-Protection": "1; mode=block"
+.Ed
+.Pp
+Further to this it applies a Modern TLS config
+.Pf (
+.Lk https://wiki.mozilla.org/Security/Server_Side_TLS "as defined by mozilla"
+.Ns ) and a fairly restrictive set of HTTP security headers.
+.Pp
+Whilst running aws will log any errors that occur to the standard error stream.
+It will also log successful connections to the standard output stream.
+These successful connection log messages follow the
+.Lk https://httpd.apache.org/docs/current/logs.html#combined "Apache Combined Log Format"
+so they can be analysed using any tools that can accept such a log format.
+.Pp
+The following options are available:
+.Bl -tag -width indent
+.It Fl c Ar directory
+Use the specified directory to store generated certificates in.
+If the directory does not exist then it will be created with the mode 700.
+By default the directory used is
+.Pa ../certs
+.Ns .
+.Ed
+.Sh EXIT STATUS
+If no hostname is specified then
+.Nm
+will exit 2.
+.Pp
+For all other errors it will exit 1.
+.Sh EXAMPLES
+Serve
+.Pa /var/www/htdocs
+as
+.Em www.alisdairmacleod.co.uk
+.Ns :
+.Pp
+.Dl # cd /var/www/htdocs && aws www.alisdairmacleod.co.uk
+.Pp
+Multiple hostnames should be space delimited:
+.Pp
+.Dl # aws www.alisdairmacleod.co.uk www.alisdairmacleod.com
+.Pp
+Using
+.Pa /var/certs
+as the directory to store certificates:
+.Pp
+.Dl # aws -c /var/certs www.alisdairmacleod.co.uk
+.Pp
+.Sh BUGS
+It might be possible to put certificates into the certificate directory to encourage
+.Nm
+to use the specified certificates rather than having ones generated.
+.Sh SECURITY CONSIDERATIONS
+.Nm
+must have access to ports 80 and 443 and so likely will have to be run as root.
blob - /dev/null
blob + 72a98fc3426674dcdcb96b64574b9bd1a914bb6f (mode 644)
--- /dev/null
+++ aws.go
@@ -0,0 +1,101 @@
+// Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"time"
+
+	server "github.com/admacleod/aws/internal"
+
+	"golang.org/x/crypto/acme/autocert"
+)
+
+const (
+	usage = `Usage: %s [OPTION] HOSTNAME ...
+Serve the current directory over HTTPS using ACME certificates for HOST(s).
+
+`
+	narg = `%[1]s: missing host operand
+Try '%[1]s -h' for more information.
+`
+)
+
+func main() {
+	flag.Usage = func() {
+		fmt.Fprintf(flag.CommandLine.Output(), usage, os.Args[0])
+		flag.PrintDefaults()
+	}
+
+	var (
+		certDir string
+	)
+	flag.StringVar(&certDir, "c", "../certs", "certificate directory")
+	flag.Parse()
+
+	if flag.NArg() == 0 {
+		fmt.Fprintf(flag.CommandLine.Output(), narg, os.Args[0])
+		os.Exit(2)
+	}
+
+	// Configure TLS and certificate management
+	mgr := autocert.Manager{
+		Prompt:     autocert.AcceptTOS,
+		HostPolicy: autocert.HostWhitelist(flag.Args()...),
+		Cache:      autocert.DirCache(certDir),
+	}
+	tlsCfg := mgr.TLSConfig()
+	server.ModerniseTLS(tlsCfg)
+
+	// Setup our handler
+	mux := &http.ServeMux{}
+	mw := server.ChainMiddleware(
+		server.SecureHeaders,
+		server.CombinedLogFormatLogger(os.Stdout),
+	)
+	handler := http.FileServer(http.Dir("."))
+	mux.Handle("/", mw(handler))
+
+	timeout := 10 * time.Second
+	errLog := log.New(os.Stderr, "aws: ", log.LstdFlags)
+	// We need two servers, one for HTTP redirect and the other for HTTPS
+	srv := server.New(
+		server.Timeout(timeout),
+		server.ErrorLog(errLog),
+		server.Handle(mgr.HTTPHandler(nil)),
+	)
+	srvTLS := server.New(
+		server.Timeout(timeout),
+		server.ErrorLog(errLog),
+		server.Handle(mux),
+		server.TLS(tlsCfg),
+	)
+
+	// Spool up and listen for errors
+	e := make(chan error, 1)
+	go func() {
+		e <- srv.ListenAndServe()
+	}()
+	go func() {
+		e <- srvTLS.ListenAndServeTLS("", "")
+	}()
+	err := <-e
+	if err != nil {
+		errLog.Fatalf("%v", err)
+	}
+}
blob - /dev/null
blob + d4eb16af0ef5ccf35cb6e82409f56b04395259eb (mode 644)
--- /dev/null
+++ go.mod
@@ -0,0 +1,10 @@
+module github.com/admacleod/aws
+
+go 1.17
+
+require golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce
+
+require (
+	golang.org/x/net v0.0.0-20220111093109-d55c255bac03 // indirect
+	golang.org/x/text v0.3.7 // indirect
+)
blob - /dev/null
blob + 37908d6816efd3890d73b3e6e708a33a2088ca09 (mode 644)
--- /dev/null
+++ go.sum
@@ -0,0 +1,13 @@
+golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce h1:Roh6XWxHFKrPgC/EQhVubSAGQ6Ozk6IdxHSzt1mR0EI=
+golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220111093109-d55c255bac03 h1:0FB83qp0AzVJm+0wcIlauAjJ+tNdh7jLuacRYCIVv7s=
+golang.org/x/net v0.0.0-20220111093109-d55c255bac03/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
blob - /dev/null
blob + 65f36fbb2cdedc6c3181524e1cedc3f7a07c73bb (mode 644)
--- /dev/null
+++ internal/headers.go
@@ -0,0 +1,51 @@
+// Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package internal
+
+import "net/http"
+
+// CSP defines the Content-Security-Policy applied by the SecureHeaders function.
+// The policy is very restrictive. Currently only allowing self-hosted CSS, images,
+// and PDF documents. JavaScript, forms, and iframes are disallowed.
+const CSP = "default-src 'none';" +
+	"style-src 'self';" +
+	"img-src 'self';" +
+	"object-src 'self';" +
+	"base-uri 'none';" +
+	"form-action 'none';" +
+	"frame-ancestors 'none';" +
+	"plugin-types application/pdf"
+
+// SecureHeaders is a http middleware for adding security headers to server responses.
+// Applying the middleware will add the following header values, inspired by
+// https://securityheaders.com, to responses from the wrapped handler.
+//
+//	Content-Security-Policy: [see CSP constant]
+//	Referrer-Policy: no-referrer
+//	Strict-Transport-Security: max-age=63072000; includeSubDomains
+//	X-Content-Type-Options: nosniff
+//	X-Frame-Options: DENY
+//	X-XSS-Protection: 1; mode=block
+func SecureHeaders(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("X-Clacks-Overhead", "GNU Terry Pratchett")
+		w.Header().Set("Content-Security-Policy", CSP)
+		w.Header().Set("Referrer-Policy", "no-referrer")
+		w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
+		w.Header().Set("X-Content-Type-Options", "nosniff")
+		w.Header().Set("X-Frame-Options", "DENY")
+		w.Header().Set("X-XSS-Protection", "1; mode=block")
+		next.ServeHTTP(w, r)
+	})
+}
blob - /dev/null
blob + f8457874bdcdc71f97255a5a8fb3076956d3ae9d (mode 644)
--- /dev/null
+++ internal/headers_test.go
@@ -0,0 +1,59 @@
+// Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package internal_test
+
+import (
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	server "github.com/admacleod/aws/internal"
+)
+
+func TestSecureHeaders(t *testing.T) {
+	testBody := "test"
+	testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if _, err := io.WriteString(w, testBody); err != nil {
+			t.Errorf("could not write testBody in testHandler: %v", err)
+		}
+	})
+
+	req := httptest.NewRequest("GET", "http://test.example.com", nil)
+	w := httptest.NewRecorder()
+
+	server.SecureHeaders(testHandler).ServeHTTP(w, req)
+
+	res := w.Result()
+	body, _ := io.ReadAll(res.Body)
+	res.Body.Close()
+
+	if string(body) != testBody {
+		t.Errorf("returned body is incorrect: expected=%s, got=%s", testBody, body)
+	}
+
+	for header, expected := range map[string]string{
+		"Content-Security-Policy":   server.CSP,
+		"Referrer-Policy":           "no-referrer",
+		"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
+		"X-Content-Type-Options":    "nosniff",
+		"X-Frame-Options":           "DENY",
+		"X-XSS-Protection":          "1; mode=block",
+	} {
+		got := res.Header.Get(header)
+		if expected != got {
+			t.Errorf("%s header is incorrect: expected=%s, got=%s", header, expected, got)
+		}
+	}
+}
blob - /dev/null
blob + fb7db25d36c2f58af027d86bff21e86ce264291b (mode 644)
--- /dev/null
+++ internal/logger.go
@@ -0,0 +1,73 @@
+// Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package internal
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"strings"
+	"time"
+)
+
+type loggerResponseWriter struct {
+	http.ResponseWriter
+	Status        int
+	ContentLength int
+}
+
+func (lrw *loggerResponseWriter) WriteHeader(code int) {
+	lrw.ResponseWriter.WriteHeader(code)
+	lrw.Status = code
+}
+
+func (lrw *loggerResponseWriter) Write(bb []byte) (int, error) {
+	length, err := lrw.ResponseWriter.Write(bb)
+	lrw.ContentLength += length
+
+	return length, err
+}
+
+// CombinedLogFormatLogger is a middleware generator function that will write an Apache Combined Log Format
+// to the passed output Writer for all requests to the wrapped handler.
+//
+// The definition of the Combined Log Format can be found at: https://httpd.apache.org/docs/2.4/logs.html#combined
+func CombinedLogFormatLogger(output io.Writer) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			start := time.Now()
+			lrw := loggerResponseWriter{w, 200, 0}
+			next.ServeHTTP(&lrw, r)
+			fmt.Fprintf(output, "%s - - [%s] \"%s %s %s\" %d %d \"%s\" \"%s\"\n",
+				strings.Split(r.RemoteAddr, ":")[0], // Remove potential port number from remote address
+				start.Format("02/Jan/2006:15:04:05 -0700"),
+				r.Method,
+				r.RequestURI,
+				r.Proto,
+				lrw.Status,
+				lrw.ContentLength,
+				r.Referer(),
+				r.UserAgent(),
+			)
+		})
+	}
+}
+
+// ErrorLog creates a server.Option function that will apply the passed log.Logger to the server as the ErrorLog.
+func ErrorLog(logger *log.Logger) Option {
+	return func(srv *Server) {
+		srv.ErrorLog = logger
+	}
+}
blob - /dev/null
blob + 7845a8582e4201307981b721e339336d43332238 (mode 644)
--- /dev/null
+++ internal/logger_test.go
@@ -0,0 +1,92 @@
+// Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package internal_test
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"reflect"
+	"strings"
+	"testing"
+	"time"
+
+	server "github.com/admacleod/aws/internal"
+)
+
+func TestLogger(t *testing.T) {
+	testBody := "test"
+	testMethod := "GET"
+	testRequestURI := "http://test.example.com"
+	testStatusCode := http.StatusCreated
+	testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(testStatusCode)
+		if _, err := io.WriteString(w, testBody); err != nil {
+			t.Errorf("could not write testBody in testHandler: %v", err)
+		}
+	})
+
+	req := httptest.NewRequest(testMethod, testRequestURI, nil)
+	w := httptest.NewRecorder()
+
+	var output bytes.Buffer
+	middleware := server.CombinedLogFormatLogger(&output)
+	start := time.Now()
+
+	middleware(testHandler).ServeHTTP(w, req)
+
+	res := w.Result()
+	body, _ := io.ReadAll(res.Body)
+	res.Body.Close()
+
+	if string(body) != testBody {
+		t.Errorf("returned body is incorrect: expected=%s, got=%s", testBody, body)
+	}
+
+	if testStatusCode != res.StatusCode {
+		t.Errorf("returned status code is incorrect: expected=%d, got=%d", testStatusCode, res.StatusCode)
+	}
+
+	expected := fmt.Sprintf("%s - - [%s] \"%s %s %s\" %d %d \"%s\" \"%s\"\n",
+		strings.Split(req.RemoteAddr, ":")[0],
+		start.Format("02/Jan/2006:15:04:05 -0700"),
+		req.Method,
+		req.RequestURI,
+		req.Proto,
+		res.StatusCode,
+		len(body),
+		req.Referer(),
+		req.UserAgent(),
+	)
+	got := output.String()
+
+	if got != expected {
+		t.Errorf("error with log output:\nexpect=\"%s\"\nactual=\"%s\"", expected, got)
+	}
+}
+
+func TestServerLoggerOption(t *testing.T) {
+	testLogger := log.New(os.Stdout, "test: ", log.LUTC)
+	testSrv := server.New(
+		server.ErrorLog(testLogger),
+	)
+
+	if !reflect.DeepEqual(testLogger, testSrv.ErrorLog) {
+		t.Errorf("incorrect server error log: expected=%v, got=%v", testLogger, testSrv.ErrorLog)
+	}
+}
blob - /dev/null
blob + e4d859a72629f7e2e0d9487e6325ccca955c7291 (mode 644)
--- /dev/null
+++ internal/middleware.go
@@ -0,0 +1,34 @@
+// Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package internal
+
+import "net/http"
+
+// ChainMiddleware combines all passed middlewares into a single middleware function.
+// Middlewares will be executed from the inside out in the order that they are passed in.
+//
+//	server.ChainMiddleware(middleware1, middleware2)(handler)
+//
+// Is equivalent to:
+//
+//	middleware2(middleware1(handler))
+func ChainMiddleware(mm ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
+	return func(final http.Handler) http.Handler {
+		for _, m := range mm {
+			final = m(final)
+		}
+
+		return final
+	}
+}
blob - /dev/null
blob + 6a66a9f9da9d8eacbcb25603ccbee607e29caa47 (mode 644)
--- /dev/null
+++ internal/middleware_test.go
@@ -0,0 +1,58 @@
+// Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package internal_test
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"testing"
+
+	server "github.com/admacleod/aws/internal"
+)
+
+func TestMiddleware(t *testing.T) {
+	var testMiddlewareCalls []string
+
+	middleware1 := func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			testMiddlewareCalls = append(testMiddlewareCalls, "middleware1")
+			next.ServeHTTP(w, r)
+		})
+	}
+	middleware2 := func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			testMiddlewareCalls = append(testMiddlewareCalls, "middleware2")
+			next.ServeHTTP(w, r)
+		})
+	}
+	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		testMiddlewareCalls = append(testMiddlewareCalls, "handler")
+	})
+
+	req := httptest.NewRequest("GET", "http://test.example.com", nil)
+	w := httptest.NewRecorder()
+
+	testMiddleware := server.ChainMiddleware(middleware1, middleware2)
+	testMiddleware(handler).ServeHTTP(w, req)
+
+	expect := []string{
+		"middleware2",
+		"middleware1",
+		"handler",
+	}
+	if !reflect.DeepEqual(expect, testMiddlewareCalls) {
+		t.Errorf("incorrect middleware calls: expected=%v, got=%v", expect, testMiddlewareCalls)
+	}
+}
blob - /dev/null
blob + 2b9678523c8539733dc0db193e1af5fc53b9a3d3 (mode 644)
--- /dev/null
+++ internal/server.go
@@ -0,0 +1,74 @@
+// Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+// Package internal provides a higher level wrapper around the standard library http.Server.
+//
+// This wrapping allows for the addition of helper functions that can be used to trivialise
+// the setup of a secure web server.
+//
+// The default http.Server is close to being safe to expose directly to the internet but misses some important settings:
+// Timeouts, TLS settings, and Response headers.
+//
+// Creating a safe, modern, web server with this package is as easy as:
+//
+//	srv := server.New(
+//		server.Timeout(120 * time.Second),
+//		server.TLS(server.ModerniseTLS(&tls.Config{})),
+//		server.Handle(server.SecureHeaders(handler)),
+//	)
+//
+// Additional configurations are also provided to simplify server creation in general.
+package internal
+
+import (
+	"net/http"
+	"time"
+)
+
+// Server defines a http server that allows for extension of the standard http.Server struct.
+type Server struct {
+	http.Server
+}
+
+// Option is a function that will apply some option to a Server object.
+type Option func(*Server)
+
+// New creates a new Server with the passed Options applied to it.
+//
+// If no options are passed then a default server implementation is returned.
+func New(opts ...Option) *Server {
+	srv := &Server{}
+
+	for _, o := range opts {
+		o(srv)
+	}
+
+	return srv
+}
+
+// Timeout creates a server.Option function that will set the passed time.Duration as the
+// ReadTimeout, WriteTimeout, and IdleTimeout for the server.
+func Timeout(timeout time.Duration) Option {
+	return func(srv *Server) {
+		srv.ReadTimeout = timeout
+		srv.WriteTimeout = timeout
+		srv.IdleTimeout = timeout
+	}
+}
+
+// Handle creates a server.Option function that will set the passed http.Handler to the server as the Handler.
+func Handle(handler http.Handler) Option {
+	return func(srv *Server) {
+		srv.Handler = handler
+	}
+}
blob - /dev/null
blob + af36d6b4a33e977cd36c95b9bb032202ae59bbc8 (mode 644)
--- /dev/null
+++ internal/server_test.go
@@ -0,0 +1,56 @@
+// Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package internal_test
+
+import (
+	"net/http"
+	"reflect"
+	"testing"
+	"time"
+
+	server "github.com/admacleod/aws/internal"
+)
+
+func TestServerInstantiation(t *testing.T) {
+	testSrv := server.New(
+		server.Timeout(10 * time.Second),
+	)
+
+	for _, tt := range []struct {
+		name     string
+		got      time.Duration
+		expected time.Duration
+	}{
+		{"ReadTimeout", testSrv.ReadTimeout, 10 * time.Second},
+		{"WriteTimeout", testSrv.WriteTimeout, 10 * time.Second},
+		{"IdleTimeout", testSrv.IdleTimeout, 10 * time.Second},
+	} {
+		if tt.got != tt.expected {
+			t.Errorf("incorrect %s: expected=%v, got=%v", tt.name, tt.expected, tt.got)
+		}
+	}
+}
+
+func TestServerHandlerOption(t *testing.T) {
+	testHandler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusNotImplemented)
+	}))
+	testSrv := server.New(
+		server.Handle(testHandler),
+	)
+
+	if reflect.DeepEqual(testHandler, testSrv.Handler) {
+		t.Errorf("incorrect server handler: expected=%v, got=%v", testHandler, testSrv.Handler)
+	}
+}
blob - /dev/null
blob + 2a319ba2c486f53d0dfb8b88eb993670252b19c5 (mode 644)
--- /dev/null
+++ internal/tls.go
@@ -0,0 +1,49 @@
+// Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package internal
+
+import (
+	"crypto/tls"
+)
+
+// ModerniseTLS modifies a tls.Config to meet Mozilla's intermediate compatibility recommendations
+// https://wiki.mozilla.org/Security/Server_Side_TLS.
+//
+// The passed tls.Config is both modified and returned so that the function may
+// optionally be used in a functional chain.
+func ModerniseTLS(t *tls.Config) *tls.Config {
+	t.CurvePreferences = []tls.CurveID{
+		tls.X25519,
+		tls.CurveP256,
+		tls.CurveP384,
+	}
+	t.MinVersion = tls.VersionTLS12
+	t.CipherSuites = []uint16{
+		tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
+		tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
+		tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
+		tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
+		tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
+		tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+	}
+
+	return t
+}
+
+// TLS creates a server.Option function that will set the passed tls.Config as the server TLSConfig.
+func TLS(cfg *tls.Config) Option {
+	return func(srv *Server) {
+		srv.TLSConfig = cfg
+	}
+}
blob - /dev/null
blob + 8aa0c42429c138cea33488b8281dba8a58a52ba2 (mode 644)
--- /dev/null
+++ internal/tls_test.go
@@ -0,0 +1,74 @@
+// Copyright (c) Alisdair MacLeod <copying@alisdairmacleod.co.uk>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package internal_test
+
+import (
+	"crypto/tls"
+	"reflect"
+	"testing"
+
+	server "github.com/admacleod/aws/internal"
+)
+
+func TestTLSModerniseConfig(t *testing.T) {
+	testConfig := &tls.Config{}
+
+	server.ModerniseTLS(testConfig)
+
+	if !reflect.DeepEqual([]tls.CurveID{
+		tls.X25519,
+		tls.CurveP256,
+		tls.CurveP384,
+	}, testConfig.CurvePreferences) {
+		t.Errorf("incorrect curve preferences: expected=%v, got=%v", []tls.CurveID{
+			tls.X25519,
+			tls.CurveP256,
+			tls.CurveP384,
+		}, testConfig.CurvePreferences)
+	}
+
+	if testConfig.MinVersion != uint16(tls.VersionTLS12) {
+		t.Errorf("incorrect minimum tls setting: expected=%v, got=%v", uint16(tls.VersionTLS12), testConfig.MinVersion)
+	}
+
+	if !reflect.DeepEqual([]uint16{
+		tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
+		tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
+		tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
+		tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
+		tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
+		tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+	}, testConfig.CipherSuites) {
+		t.Errorf("incorrect cipher suite preferences: expected=%v, got=%v", []uint16{
+			tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
+			tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
+			tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
+			tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
+			tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
+			tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+		}, testConfig.CipherSuites)
+	}
+}
+
+func TestTLSServerOption(t *testing.T) {
+	testConfig := &tls.Config{}
+	server.ModerniseTLS(testConfig)
+	testSrv := server.New(
+		server.TLS(testConfig),
+	)
+
+	if !reflect.DeepEqual(testConfig, testSrv.TLSConfig) {
+		t.Errorf("incorrect tls config: expected=%v, got=%v", testConfig, testSrv.TLSConfig)
+	}
+}