author | Alberto Bertogli
<albertito@blitiri.com.ar> 2020-05-05 18:33:43 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2020-05-06 02:25:38 UTC |
parent | a444349ba7116e4cac1157145d4886275eb7007d |
.gitignore | +3 | -1 |
Makefile | +23 | -0 |
go.sum | +1 | -0 |
test/01-be.conf | +11 | -0 |
test/01-fe.conf | +39 | -0 |
test/test.sh | +148 | -0 |
test/testdata/cgi.sh | +16 | -0 |
test/testdata/dir/hola | +1 | -0 |
test/testdata/dir/ñaca | +1 | -0 |
test/testdata/file | +1 | -0 |
test/util/exp.go | +136 | -0 |
test/util/generate_cert.go | +123 | -0 |
diff --git a/.gitignore b/.gitignore index 664073b..0d967e7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,11 @@ # excluded. !.gitignore -# The binary. +# The binaries. gofer +test/util/exp # Configuration and certificates, to prevent accidents. *.conf *.pem +!test/*.conf diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b102122 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ + +ifndef VERSION + VERSION = `git describe --always --long --dirty` +endif + +# https://wiki.debian.org/ReproducibleBuilds/TimestampsProposal +ifndef SOURCE_DATE_EPOCH + SOURCE_DATE_EPOCH = `git log -1 --format=%ct` +endif + +default: gofer + +gofer: + go build -ldflags="\ + -X main.version=${VERSION} \ + -X main.sourceDateTs=${SOURCE_DATE_EPOCH} \ + " ${GOFLAGS} + +test: + go test ./... + setsid -w ./test/test.sh + +.PHONY: gofer test diff --git a/go.sum b/go.sum index 96efb0d..5b34b45 100644 --- a/go.sum +++ b/go.sum @@ -9,4 +9,5 @@ golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbP golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/test/01-be.conf b/test/01-be.conf new file mode 100644 index 0000000..c60d021 --- /dev/null +++ b/test/01-be.conf @@ -0,0 +1,11 @@ + +control_addr = "127.0.0.1:8459" + +[[http]] +addr = ":8450" + +[http.routes] +"/dir/" = "dir:testdata/dir" +"/file" = "static:testdata/file" +"/file/second" = "static:testdata/dir/ñaca" +"/cgi/" = "cgi:testdata/cgi.sh?param 1;param 2" diff --git a/test/01-fe.conf b/test/01-fe.conf new file mode 100644 index 0000000..bfc8d07 --- /dev/null +++ b/test/01-fe.conf @@ -0,0 +1,39 @@ + +control_addr = "127.0.0.1:8440" + +[[http]] +addr = ":8441" +base_routes = "default" + +[[https]] +addr = ":8442" +certs = ".certs" +base_routes = "default" + +[https.routes] +"/dar/" = "http://localhost:8450/dir/" + +[routes.default] +"/dir/" = "http://localhost:8450/dir/" +"/file" = "http://localhost:8450/file" +"/cgi/" = "http://localhost:8450/cgi/" +"/gogo/" = "redirect:https://google.com" + +# Target unreachable: connection refused. +"/bad/unreacheable" = "http://localhost:1/" + +# Bad target. +"/bad/empty" = "http:" + + +# Raw proxy to the same backend. +[[raw]] +addr = ":8445" +to = "localhost:8450" +to_tls = false + +[[raw]] +addr = ":8446" +certs = ".certs" +to = "localhost:8450" +to_tls = false diff --git a/test/test.sh b/test/test.sh new file mode 100755 index 0000000..61aced8 --- /dev/null +++ b/test/test.sh @@ -0,0 +1,148 @@ +#!/bin/bash + +set -e + +if [ "$V" == "1" ]; then + set -v +fi + +UTILDIR="$( realpath `dirname "${0}"` )/util" + +# Set traps to kill our subprocesses when we exit (for any reason). +trap ":" TERM # Avoid the EXIT handler from killing bash. +trap "exit 2" INT # Ctrl-C, make sure we fail in that case. +trap "kill 0" EXIT # Kill children on exit. + +# The tests are run from the test root. +cd "$(realpath `dirname ${0}`)/" + +# Build the binaries. +if [ "$COVER_DIR" != "" ]; then + ( + cd .. + go test -covermode=count -coverpkg=./... -c -tags coveragebin + mv gofer.test gofer + ) +else + ( cd ..; go build ) +fi +( cd util; go build exp.go ) + + +# Run gofer in the background (sets $PID to its process id). +function gofer() { + # Set the coverage arguments each time, as we don't want the different + # runs to override the generated profile. + if [ "$COVER_DIR" != "" ]; then + COVER_ARGS="-test.run=^TestRunMain$ \ + -test.coverprofile=$COVER_DIR/it-`date +%s.%N`.out" + fi + + $SYSTEMD_ACTIVATE ../gofer $COVER_ARGS \ + -v=3 \ + "$@" >> .out.log 2>&1 & + PID=$! +} + +# Wait until there's something listening on the given port. +function wait_until_ready() { + PORT=$1 + + while ! bash -c "true < /dev/tcp/localhost/$PORT" 2>/dev/null ; do + sleep 0.01 + done +} + +function generate_certs() { + mkdir -p .certs/localhost + ( + cd .certs/localhost + go run ${UTILDIR}/generate_cert.go \ + -ca -duration=1h --host=localhost + ) +} + +function curl() { + curl --cacert ".certs/localhost/fullchain.pem" "$@" +} + +function exp() { + if [ "$V" == "1" ]; then + VF="-v" + fi + echo " $@" + ${UTILDIR}/exp "$@" \ + $VF \ + -cacert=".certs/localhost/fullchain.pem" +} + +function snoop() { + if [ "$SNOOP" == "1" ]; then + read -p"Press enter to continue" + fi +} + +echo "## Setup" + +# Launch the backend serving static files and CGI. +gofer -logfile=.01-be.log -configfile=01-be.conf +DIR_PID=$PID +wait_until_ready 8450 + +# Launch the test instance. +generate_certs +gofer -logfile=.01-fe.log -configfile=01-fe.conf +wait_until_ready 8441 # http +wait_until_ready 8442 # https +wait_until_ready 8445 # raw + +snoop + +# +# Test cases. +# +echo "## Tests" + +# Common tests, for both servers. +for base in \ + http://localhost:8441 \ + https://localhost:8442 ; +do + exp $base/file -body "ñaca\n" + + exp $base/dir -status 301 -redir /dir/ + exp $base/dir/ -bodyre '<a href="%C3%B1aca">ñaca</a>' + exp $base/dir/hola -body 'hola marola\n' + exp $base/dir/ñaca -body "tracañaca\n" + + exp $base/cgi/ -bodyre '"param 1" "param 2"' + exp "$base/cgi/?cucu=melo&a;b" -bodyre 'QUERY_STRING=cucu=melo&a;b\n' + + exp $base/gogo/ -status 307 -redir https://google.com/ + exp $base/gogo/gaga -status 307 -redir https://google.com/gaga + exp $base/gogo/a/b/ -status 307 -redir https://google.com/a/b/ + + exp $base/bad/unreacheable -status 502 + exp $base/bad/empty -status 502 + + # Test that the FE doesn't forward this - it exists on the BE, but the + # route doesn't end in a / so it shouldn't be forwarded. + exp $base/file/second -status 404 + + # Interesting case because neither has a trailing "/", so check that + # the striping is done correctly. + exp $base/file/ -status 404 +done + +# HTTPS-only tests. +exp https://localhost:8442/dar/ -bodyre '<a href="%C3%B1aca">ñaca</a>' + +# We rely on the BE having this, so check to avoid false positives due to +# misconfiguration. +exp http://localhost:8450/file/second -body "tracañaca\n" + +# Raw proxying. +exp http://localhost:8445/file -body "ñaca\n" +exp https://localhost:8446/file -body "ñaca\n" + +snoop diff --git a/test/testdata/cgi.sh b/test/testdata/cgi.sh new file mode 100755 index 0000000..6227519 --- /dev/null +++ b/test/testdata/cgi.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +echo "Content-type: text/plain" +echo + +echo -n ARGS: +for i in "$@"; do + echo -n " \"$i\"" +done +echo + +echo +env | sort + diff --git a/test/testdata/dir/hola b/test/testdata/dir/hola new file mode 100644 index 0000000..f7d6dee --- /dev/null +++ b/test/testdata/dir/hola @@ -0,0 +1 @@ +hola marola diff --git "a/test/testdata/dir/\303\261aca" "b/test/testdata/dir/\303\261aca" new file mode 100644 index 0000000..bd8194c --- /dev/null +++ "b/test/testdata/dir/\303\261aca" @@ -0,0 +1 @@ +tracañaca diff --git a/test/testdata/file b/test/testdata/file new file mode 100644 index 0000000..e94300e --- /dev/null +++ b/test/testdata/file @@ -0,0 +1 @@ +ñaca diff --git a/test/util/exp.go b/test/util/exp.go new file mode 100644 index 0000000..530a0ce --- /dev/null +++ b/test/util/exp.go @@ -0,0 +1,136 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "flag" + "fmt" + "io/ioutil" + "net/http" + "os" + "regexp" + "sort" + "strconv" + "strings" +) + +var exitCode int = 0 + +func main() { + // The first arg is the URL, and then we shift. + url := os.Args[1] + os.Args = append([]string{os.Args[0]}, os.Args[2:]...) + + var ( + body = flag.String("body", "", + "expect body with these exact contents") + bodyRE = flag.String("bodyre", "", + "expect body matching these contents (regexp match)") + redir = flag.String("redir", "", + "expect a redirect to this URL") + status = flag.Int("status", 200, + "expect this status code") + verbose = flag.Bool("v", false, + "enable verbose output") + caCert = flag.String("cacert", "", + "file to read CA cert from") + ) + flag.Parse() + + client := &http.Client{ + CheckRedirect: noRedirect, + Transport: mkTransport(*caCert), + } + + resp, err := client.Get(url) + if err != nil { + fatalf("error getting %q: %v\n", url, err) + } + defer resp.Body.Close() + rbody, err := ioutil.ReadAll(resp.Body) + if err != nil { + errorf("error reading body: %v\n", err) + } + + if *verbose { + fmt.Printf("Request: %s\n", url) + fmt.Printf("Response:\n") + fmt.Printf(" %v %v\n", resp.Proto, resp.Status) + ks := []string{} + for k, _ := range resp.Header { + ks = append(ks, k) + } + sort.Strings(ks) + for _, k := range ks { + fmt.Printf(" %v: %s\n", k, + strings.Join(resp.Header.Values(k), ", ")) + } + fmt.Printf("\n") + } + + if resp.StatusCode != *status { + errorf("status is not %d: %q\n", *status, resp.Status) + } + + if *body != "" { + // Unescape the body to allow control characters more easily. + *body, _ = strconv.Unquote("\"" + *body + "\"") + if string(rbody) != *body { + errorf("unexpected body: %q\n", rbody) + } + } + + if *bodyRE != "" { + matched, err := regexp.Match(*bodyRE, rbody) + if err != nil { + errorf("regexp error: %q", err) + } + if !matched { + errorf("body did not match regexp: %q\n", rbody) + } + } + + if *redir != "" { + if loc := resp.Header.Get("Location"); loc != *redir { + errorf("unexpected redir location: %q\n", loc) + } + } + + os.Exit(exitCode) +} + +func noRedirect(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse +} + +func mkTransport(caCert string) *http.Transport { + if caCert == "" { + return nil + } + + certs, err := ioutil.ReadFile(caCert) + if err != nil { + fatalf("error reading CA file %q: %v", caCert, err) + } + + rootCAs := x509.NewCertPool() + if ok := rootCAs.AppendCertsFromPEM(certs); !ok { + fatalf("error adding certs to root") + } + + return &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: rootCAs, + }, + } +} + +func fatalf(s string, a ...interface{}) { + fmt.Fprintf(os.Stderr, s, a...) + os.Exit(1) +} + +func errorf(s string, a ...interface{}) { + fmt.Fprintf(os.Stderr, s, a...) + exitCode = 1 +} diff --git a/test/util/generate_cert.go b/test/util/generate_cert.go new file mode 100644 index 0000000..4fbf706 --- /dev/null +++ b/test/util/generate_cert.go @@ -0,0 +1,123 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build ignore + +// Generate a self-signed X.509 certificate for a TLS server. Outputs to +// 'cert.pem' and 'key.pem' and will overwrite existing files. + +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "flag" + "fmt" + "log" + "math/big" + "net" + "os" + "strings" + "time" + + "golang.org/x/net/idna" +) + +var ( + host = flag.String("host", "", "Comma-separated hostnames and IPs to generate a certificate for") + validFrom = flag.String("start-date", "", "Creation date formatted as Jan 1 15:04:05 2011") + validFor = flag.Duration("duration", 365*24*time.Hour, "Duration that certificate is valid for") + isCA = flag.Bool("ca", false, "whether this cert should be its own Certificate Authority") +) + +func main() { + flag.Parse() + + if len(*host) == 0 { + log.Fatalf("Missing required --host parameter") + } + + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + log.Fatalf("failed to generate private key: %s", err) + } + + var notBefore time.Time + if len(*validFrom) == 0 { + notBefore = time.Now() + } else { + notBefore, err = time.Parse("Jan 2 15:04:05 2006", *validFrom) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to parse creation date: %s\n", err) + os.Exit(1) + } + } + + notAfter := notBefore.Add(*validFor) + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + log.Fatalf("failed to generate serial number: %s", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + hosts := strings.Split(*host, ",") + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + // We use IDNA-encoded DNS names, otherwise the TLS library won't + // load the certificates. + ih, err := idna.ToASCII(h) + if err != nil { + log.Fatalf("host %q cannot be IDNA-encoded: %v", h, err) + } + template.DNSNames = append(template.DNSNames, ih) + } + } + + if *isCA { + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + log.Fatalf("Failed to create certificate: %s", err) + } + + certOut, err := os.Create("fullchain.pem") + if err != nil { + log.Fatalf("failed to open fullchain.pem for writing: %s", err) + } + pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + certOut.Close() + + keyOut, err := os.OpenFile("privkey.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + log.Fatalf("failed to open privkey.pem for writing: %s", err) + return + } + + block := &pem.Block{Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(priv)} + pem.Encode(keyOut, block) + keyOut.Close() +}