~ancarda/tls-redirector

e4636a57dc63f656f5b8db32dd716c84038a09be — Mark Dain 4 months ago 1ebcd72 2.4
2.4: Nice looking HTML error pages

This commit changes tls-redirector to output descriptive, nice looking
HTML error pages rather than the one-line text it used to output. This
is aimed at providing any would-be visitors with information indicating
why they are seeing pages from tls-redirector, and not the website
they (presumably) intended to visit.

The HTML is compiled into the binary by using `go generate' to copy
files on disk into constants.
M Dockerfile => Dockerfile +1 -1
@@ 4,7 4,7 @@ RUN apk add git binutils
COPY go.* ./
RUN go mod download
COPY . ./
RUN go build . && strip tls-redirector
RUN go generate ./... && go build . && strip tls-redirector

FROM alpine:3.12.0
COPY --from=builder /go/src/git.sr.ht/~ancarda/tls-redirector/tls-redirector .

M README.md => README.md +8 -0
@@ 38,6 38,14 @@ up once and forget about it.

## How To Build

The first time you checkout this project, you'll need to run this command:

    go generate ./...

Re-run that anytime you edit files inside `fancy/html`.

----

If you want systemd socket activation, you need to compile this way:

    go build -tags systemd

M cli_test.go => cli_test.go +1 -1
@@ 13,7 13,7 @@ func TestCLIVersionFlag(t *testing.T) {
	args := []string{"./test", "--version"}

	assert.Equal(t, 0, handleCliArgs(&buf, args, false))
	assert.Equal(t, "tls redirector 2.3\n", buf.String())
	assert.Equal(t, "tls redirector 2.4\n", buf.String())
}

func TestCLIUnknownFlag(t *testing.T) {

A fancy/fancy.go => fancy/fancy.go +32 -0
@@ 0,0 1,32 @@
//go:generate go run generate/cmd.go

package fancy

import (
	"bytes"
	"text/template"
)

var pageT *template.Template

func init() { pageT = template.Must(template.New("").Parse(pageTemplate)) }

// ErrorPage produces a nice looking HTML error page.
func ErrorPage(title, message, ti, ver string) []byte {
	footer := "<footer>Powered by tls-redirector/" + ver + "</footer>"

	dat := struct {
		Title   string
		Message string
		TI      string
		Footer  string
	}{title, message, ti, footer}

	var buf bytes.Buffer
	err := pageT.Execute(&buf, dat)
	if err != nil {
		panic(err)
	}

	return buf.Bytes()
}

A fancy/fancy_test.go => fancy/fancy_test.go +37 -0
@@ 0,0 1,37 @@
package fancy

import (
	"math/rand"
	"reflect"
	"testing"
	"testing/quick"
	"time"

	"github.com/stretchr/testify/assert"
)

func randomString() string {
	v, ok := quick.Value(reflect.TypeOf(""),
		rand.New(rand.NewSource(time.Now().Unix())))

	if !ok {
		panic("wasn't able to generate a string")
	}

	return v.String()
}

func TestErrorPage(t *testing.T) {
	title := randomString()
	message := randomString()
	techInfo := randomString()
	version := randomString()

	page := string(ErrorPage(title, message, techInfo, version))

	assert.Contains(t, page, "<title>"+title+"</title>")
	assert.Contains(t, page, "<h1>"+title+"</h1>")
	assert.Contains(t, page, message)
	assert.Contains(t, page, techInfo)
	assert.Contains(t, page, "tls-redirector/"+version)
}

A fancy/generate/cmd.go => fancy/generate/cmd.go +44 -0
@@ 0,0 1,44 @@
package main

import (
	"bytes"
	"io/ioutil"
	"os"
	"strings"
)

func main() {
	files, err := ioutil.ReadDir("html")
	if err != nil {
		panic(err)
	}

	var buf bytes.Buffer
	buf.WriteString("package fancy\n\n")
	buf.WriteString("// Autogenerated -- DO NOT EDIT BY HAND.\n\n")
	buf.WriteString("const (\n")

	for _, file := range files {
		fileName := file.Name()

		if strings.HasPrefix(fileName, ".") {
			continue
		}

		fileName = strings.Split(fileName, ".")[0]

		src, err := ioutil.ReadFile("html" + string(os.PathSeparator) + fileName + ".html")
		if err != nil {
			panic(err)
		}

		buf.WriteString("\t// " + fileName + " is a copy of html/" + fileName + ".html\n")
		buf.WriteString("\t" + fileName + " = `")
		buf.Write(src)
		buf.WriteString("`\n")
	}

	buf.WriteString(")\n")

	ioutil.WriteFile("templates.go", buf.Bytes(), 0755)
}

A fancy/html/Acme404Message.html => fancy/html/Acme404Message.html +1 -0
@@ 0,0 1,1 @@
<p>You have requested an ACME HTTP challenge that doesn't exist.</p>

A fancy/html/Acme404TI.html => fancy/html/Acme404TI.html +1 -0
@@ 0,0 1,1 @@
Requested path: <code>%s</code>

A fancy/html/EmptyHostHeader.html => fancy/html/EmptyHostHeader.html +2 -0
@@ 0,0 1,2 @@
<p>HTTP request <code>Host</code> header is empty or wasn't sent.</p>
<p>Expected a domain name such as <code>example.com</code>.</p>

A fancy/html/GenericMessage.html => fancy/html/GenericMessage.html +3 -0
@@ 0,0 1,3 @@
<p>You've reached this webserver in an insecure way.</p>
<p>Normally, you would be taken to the secure version of the website.</p>
<p>Unfortunately, that isn't possible right now. Either you visited this webserver directly &mdash; using its IP address rather than a domain name &mdash; or your webbrowser is broken somehow.</p>

A fancy/html/HostHeaderIsIPTechInfo.html => fancy/html/HostHeaderIsIPTechInfo.html +2 -0
@@ 0,0 1,2 @@
<p>HTTP request <code>Host</code> header has value <code>%s</code>, which looks like an IP address.</p>
<p>Expected a domain name such as <code>example.com</code>.</p>

A fancy/html/pageTemplate.html => fancy/html/pageTemplate.html +61 -0
@@ 0,0 1,61 @@
<!doctype html>
<html lang="en-us">
	<head>
		<meta charset="utf-8" />
		<title>{{.Title}}</title>
		<style>
			html { box-sizing: border-box }
			body {
				background-color: lightgray;
				margin: 2em;
				font-family: sans-serif;
				font-size: 18px;
				line-height: 1.4em;
			}
			main {
				background-color: white;
				padding: 2em;
				border: 4px solid gray;
			}
			h1 { margin-top: 0 }
			hr { border-top: 4px solid gray; border-bottom: none }
			a { color: blue }
			code { background-color: peachpuff }
			footer {
				padding: 1em;
				text-align: center;
				font-size: 0.8em;
			}
			.block {
				border-left: 4px solid skyblue;
				background: azure;
			}
			.block > header {
				background-color: skyblue;
				padding: 0 0.5em;
				text-transform: uppercase;
				font-size: 0.8em;
				color: white;
				font-weight: bold;
			}
			.block > article { padding: 1em }
			.block > article p:first-child { margin-top: 0 }
			.block > article p:last-child { margin-bottom: 0 }
		</style>
	</head>

	<body>
		<main>
			<h1>{{.Title}}</h1>
			{{.Message}}
			<div class="block">
				<header>Technical Information</header>
				<article>
					{{.TI}}
					<p><small>If this looks like a bug, please go to <a href="https://sr.ht/~ancarda/tls-redirector/">https://sr.ht/~ancarda/tls-redirector</a> to find out how to file a bug report.</small></p>
				</article>
			</div>
		</main>
		{{.Footer}}
	</body>
</html>

M http.go => http.go +30 -6
@@ 1,11 1,15 @@
package main

import (
	"fmt"
	"html"
	"io/ioutil"
	"net/http"
	"os"
	"strings"

	"git.sr.ht/~ancarda/tls-redirector/fancy"

	"github.com/spf13/afero"
)



@@ 20,13 24,18 @@ func newApp(acd string) app {

func (a app) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Server", "tls-redirector/"+version)
	w.Header().Set("Content-Type", "text/plain")
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("X-Content-Type-Options", "nosniff")

	// If we haven't been given a host, just abort.
	if r.Host == "" {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("tls-redirector cannot handle this request because no `host` header was sent by your browser.\n"))
		w.Write(fancy.ErrorPage(
			"Bad Request",
			fancy.GenericMessage,
			fancy.EmptyHostHeader,
			version,
		))
		return
	}



@@ 37,22 46,38 @@ func (a app) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// real web server some effort by dropping it now.
	if isIPAddress(r.Host) {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("tls-redirector cannot redirect IP addresses.\n"))
		w.Write(fancy.ErrorPage(
			"400 Bad Request",
			fancy.GenericMessage,
			fmt.Sprintf(
				fancy.HostHeaderIsIPTechInfo,
				html.EscapeString(r.Host),
			),
			version,
		))
		return
	}

	// If we are serving the ACME HTTP challenges, handle that here.
	if a.acmeChallengeDir != "" {
		if strings.HasPrefix(r.URL.Path, acmeChallengeURLPrefix) {
			id := strings.TrimPrefix(r.URL.Path, acmeChallengeURLPrefix)
			id := strings.TrimPrefix(r.URL.Path,
				acmeChallengeURLPrefix)
			b, err := readFile(a.fs,
				a.acmeChallengeDir+string(os.PathSeparator)+id)
			if err != nil {
				w.WriteHeader(http.StatusNotFound)
				w.Write([]byte("File Not Found\n"))
				w.Write(fancy.ErrorPage(
					"404 File Not Found",
					fancy.Acme404Message,
					fmt.Sprintf(fancy.Acme404TI,
						html.EscapeString(r.URL.Path)),
					version,
				))
				return
			}

			w.Header().Set("Content-Type", "text/plain")
			w.Write(b)
			return
		}


@@ 62,7 87,6 @@ func (a app) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// Change host as well as in r.URL, it's empty.
	r.URL.Host = r.Host
	r.URL.Scheme = "https"
	w.Header().Set("Content-Type", "text/html")
	http.Redirect(w, r, r.URL.String(), http.StatusMovedPermanently)
}


M http_test.go => http_test.go +8 -9
@@ 17,7 17,7 @@ import (

const (
	TextPlain = "text/plain"
	TextHTML  = "text/html"
	TextHTML  = "text/html; charset=utf-8"
)

func randomString() string {


@@ 33,7 33,7 @@ func randomString() string {

func assertionsCommonToAllResponses(t *testing.T, res *http.Response) {
	assert.Equal(t, "nosniff", res.Header.Get("X-Content-Type-Options"))
	assert.Equal(t, "tls-redirector/2.3", res.Header.Get("Server"))
	assert.Equal(t, "tls-redirector/2.4", res.Header.Get("Server"))
}

func TestNewApp_UsesRealFileSystem(t *testing.T) {


@@ 58,12 58,11 @@ func TestServer_ServeACME_404(t *testing.T) {
	res := rr.Result()

	assert.Equal(t, http.StatusNotFound, res.StatusCode)
	assert.Equal(t, TextPlain, res.Header.Get("Content-Type"))
	assert.Equal(t, TextHTML, res.Header.Get("Content-Type"))
	assertionsCommonToAllResponses(t, res)

	body, _ := ioutil.ReadAll(res.Body)
	assert.Equal(t, "File Not Found\n", string(body))

	assert.Contains(t, string(body), "File Not Found")
}

func TestServer_ServeACME_HappyPath(t *testing.T) {


@@ 91,11 90,11 @@ func TestServer_NoHostHeader_WillError(t *testing.T) {
	res := rr.Result()

	assert.Equal(t, http.StatusBadRequest, res.StatusCode)
	assert.Equal(t, TextPlain, res.Header.Get("Content-Type"))
	assert.Equal(t, TextHTML, res.Header.Get("Content-Type"))
	assertionsCommonToAllResponses(t, res)

	body, _ := ioutil.ReadAll(res.Body)
	assert.Contains(t, string(body), "no `host` header was sent")
	assert.Contains(t, string(body), "header is empty or wasn't sent")
}

func TestServer_IPAddressHostHeader_IsRejected(t *testing.T) {


@@ 105,11 104,11 @@ func TestServer_IPAddressHostHeader_IsRejected(t *testing.T) {
	res := rr.Result()

	assert.Equal(t, http.StatusBadRequest, res.StatusCode)
	assert.Equal(t, TextPlain, res.Header.Get("Content-Type"))
	assert.Equal(t, TextHTML, res.Header.Get("Content-Type"))
	assertionsCommonToAllResponses(t, res)

	body, _ := ioutil.ReadAll(res.Body)
	assert.Contains(t, string(body), "cannot redirect IP addresses")
	assert.Contains(t, string(body), "looks like an IP address")
}

func TestServer_HappyPath(t *testing.T) {

M main.go => main.go +1 -1
@@ 10,7 10,7 @@ import (

const (
	acmeChallengeURLPrefix = "/.well-known/acme-challenge/"
	version                = "2.3"
	version                = "2.4"
	defaultPort            = "80"
)