git » gofer » commit 5195e1f

config: Put all routes under the same map

author Alberto Bertogli
2020-06-13 00:55:12 UTC
committer Alberto Bertogli
2020-06-13 11:49:42 UTC
parent e7866d3c6d850c8ecaf1d4402572afe3379a1adb

config: Put all routes under the same map

This patch changes the configuration structure, putting all routes under
the same map.

The objective is to make configuration more readable, even at the cost
of a bit more verbosity in some scenarios (such as many routes of the
same type).

config/config.go +39 -7
config/config_test.go +36 -23
etc/gofer.schema.cue +21 -17
etc/gofer.yaml +34 -44
go.mod +1 -0
go.sum +4 -0
server/http.go +43 -74
server/server_test.go +6 -9
test/01-be.yaml +24 -18
test/01-fe.yaml +21 -16

diff --git a/config/config.go b/config/config.go
index dcb1d5b..9e6f236 100644
--- a/config/config.go
+++ b/config/config.go
@@ -4,6 +4,7 @@ package config
 import (
 	"fmt"
 	"io/ioutil"
+	"net/url"
 	"regexp"
 
 	"gopkg.in/yaml.v3"
@@ -21,16 +22,10 @@ type Config struct {
 }
 
 type HTTP struct {
-	Proxy    map[string]string
-	Dir      map[string]string
-	File     map[string]string
-	Redirect map[string]string
-	CGI      map[string]string
-	Status   map[string]int
+	Routes map[string]Route
 
 	Auth map[string]string
 
-	DirOpts   map[string]DirOpts
 	SetHeader map[string]map[string]string
 
 	ReqLog map[string]string
@@ -41,6 +36,16 @@ type HTTPS struct {
 	Certs string
 }
 
+type Route struct {
+	Dir      string
+	File     string
+	Proxy    *URL
+	Redirect *URL
+	CGI      []string
+	Status   int
+	DirOpts  DirOpts
+}
+
 type DirOpts struct {
 	Listing map[string]bool
 	Exclude []Regexp
@@ -106,3 +111,30 @@ func (re *Regexp) UnmarshalYAML(unmarshal func(interface{}) error) error {
 	re.Regexp = rx
 	return nil
 }
+
+// Wrapper to simplify URLs in configuration.
+type URL url.URL
+
+func (u *URL) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	var s string
+	if err := unmarshal(&s); err != nil {
+		return err
+	}
+
+	x, err := url.Parse(s)
+	if err != nil {
+		return err
+	}
+
+	*u = URL(*x)
+	return nil
+}
+
+func (u *URL) URL() url.URL {
+	return url.URL(*u)
+}
+
+func (u URL) String() string {
+	p := u.URL()
+	return p.String()
+}
diff --git a/config/config_test.go b/config/config_test.go
index 22d17c0..496790b 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -2,10 +2,20 @@ package config
 
 import (
 	"log"
-	"reflect"
+	"net/url"
 	"testing"
+
+	"github.com/google/go-cmp/cmp"
 )
 
+func mustURL(r string) *URL {
+	u, err := url.Parse(r)
+	if err != nil {
+		panic(err)
+	}
+	return (*URL)(u)
+}
+
 func TestSimple(t *testing.T) {
 	// Note: no TABs in the contents, they are not valid indentation in yaml
 	// and the parser complains about "found character that cannot start any
@@ -19,23 +29,28 @@ raw:
     to: "blerg.com:1995"
     to_tls: true
 
-_proxy: &proxy
-  "/": "http://def/"
-  "/common": "http://common/"
+_routes: &routes
+  "/":
+    proxy: "http://def/"
+  "/dir":
+    dir: "/tmp"
 
 http:
   ":http":
-    proxy:
-      <<: *proxy
-      "/srv": "http://srv/"
+    routes:
+      <<: *routes
+      "/srv":
+        proxy: "http://srv/"
 
 https:
   ":https":
     certs: "/etc/letsencrypt/live/"
-    proxy:
-      <<: *proxy
-      "/": "http://tlsoverrides/"
-      "/srv": "http://srv2/"
+    routes:
+      <<: *routes
+      "/":
+        proxy: "http://tlsoverrides/"
+      "/srv":
+        proxy: "http://srv2/"
 `
 
 	expected := Config{
@@ -49,20 +64,20 @@ https:
 		},
 		HTTP: map[string]HTTP{
 			":http": {
-				Proxy: map[string]string{
-					"/":       "http://def/",
-					"/common": "http://common/",
-					"/srv":    "http://srv/",
+				Routes: map[string]Route{
+					"/":    {Proxy: mustURL("http://def/")},
+					"/dir": {Dir: "/tmp"},
+					"/srv": {Proxy: mustURL("http://srv/")},
 				},
 			},
 		},
 		HTTPS: map[string]HTTPS{
 			":https": {
 				HTTP: HTTP{
-					Proxy: map[string]string{
-						"/":       "http://tlsoverrides/",
-						"/common": "http://common/",
-						"/srv":    "http://srv2/",
+					Routes: map[string]Route{
+						"/":    {Proxy: mustURL("http://tlsoverrides/")},
+						"/dir": {Dir: "/tmp"},
+						"/srv": {Proxy: mustURL("http://srv2/")},
 					},
 				},
 				Certs: "/etc/letsencrypt/live/",
@@ -75,9 +90,7 @@ https:
 		log.Fatal(err)
 	}
 
-	if !reflect.DeepEqual(*conf, expected) {
-		t.Errorf("configuration is not as expected")
-		t.Errorf("  expected: %v", expected.String())
-		t.Errorf("  got:      %v", conf.String())
+	if diff := cmp.Diff(expected, *conf); diff != "" {
+		t.Errorf("configuration is not as expected (-want +got):\n%s", diff)
 	}
 }
diff --git a/etc/gofer.schema.cue b/etc/gofer.schema.cue
index bf878d3..4b4ba51 100644
--- a/etc/gofer.schema.cue
+++ b/etc/gofer.schema.cue
@@ -15,39 +15,43 @@ reqlog?:
 	})
 
 http?:
-	[string]: close(_http)
+	[string]: close(#http)
 
 https?:
-	[string]: close(_http & {
+	[string]: close(#http & {
 		certs: string
 	})
 
-_http: {
-	dir?: [string]: string
+#http: {
+	routes: [string]: {
+		dir?: string
+		file?: string
+		proxy?: string
+		redirect?: string
+		cgi?: [string, ...string]
+		status?: int
 
-	file?: [string]: string
+		// TODO: Check that only one of the above is set.
 
-	proxy?: [string]: string
+		diropts?: {
+			listing?: [string]: bool
+			exclude?: [string]
+		}
 
-	redirect?: [string]: string
+		// If diropts is set, then dir must be set too.
+		if diropts != null {
+			dir: string
+		}
 
-	cgi?: [string]: string
-
-	status?: [string]: int
+	}
 
 	auth?: [string]: string
 
 	setheader?: [string]: [string]: string
 
-	diropts?: [string]: #diropts
-
 	reqlog?: [string]: string
-}
-
-#diropts:: {
-	listing?: [string]: bool
 
-	exclude?: [string]
+	...
 }
 
 raw?:
diff --git a/etc/gofer.yaml b/etc/gofer.yaml
index 6170c82..e908a24 100644
--- a/etc/gofer.yaml
+++ b/etc/gofer.yaml
@@ -30,37 +30,42 @@ http:
   # set up a systemd socket unit with "FileDescriptorName=name".
   # Examples: ":80", "127.0.0.1:8080", "&http".
   "&http":
-
-    # The following options all have the same structure: the route type, and
-    # within, a series of <path>: <target>.
+    # Routes indicate how to handle each request based on its path.
     # The path have the semantics of http.ServeMux.
-    # The meaning of the target is type-specific.
+    routes:
+      # Path: action.
+      "/":
+        # Serve the directory at the given path.
+        dir: "/srv/www/"
+
+        # Other possible actions follow. Only one per path.
 
-    # Serve the directory at the given path.
-    dir:
-      "/": "/srv/www/"
-      #"/other": "/srv/other/"
+        # Serve a single file.
+        #file: "/srv/files/file"
 
-    # Individual files.
-    #file:
-    #  "/a/file": "/srv/files/file"
+        # Proxy requests.
+        #proxy: "http://localhost:8080/api/"
 
-    # Proxy requests.
-    #proxy:
-    #  "/api/v1/": "http://localhost:8080/api/"
+        # Redirect to a different URL.
+        #redirect: "https://wikipedia.org"
 
-    # Redirect to a different URL.
-    #redirect:
-    #  "/wiki": "https://wikipedia.org"
+        # Execute a CGI.
+        #cgi: ["/usr/share/gitweb/gitweb.cgi"]
 
-    # Execute a CGI.
-    #cgi:
-    #  "/gitweb": "/usr/share/gitweb/gitweb.cgi"
+        # Return a specific status.
+        #status: 404
 
-    # Return a specific status. Can be useful to return 404 on specific
-    # sub-paths.
-    #status:
-    #  "/notfound": 404
+        # Options for the "dir" type.
+        diropts:
+          # Enable listing when index.html is not present?
+          listing:
+            "/": false
+            "/pub/": true
+
+          # Exclude files matching these regular expressions. They won't appear
+          # in listings, and won't be served to users (404 will be returned
+          # instead).
+          #exclude: [".*\\.secret", ".*/config"]
 
     # Enforce authentication on these paths. The target is the file containing
     # the user and passwords.
@@ -72,23 +77,6 @@ http:
     #  "/":
     #    "My-Header": "my header value"
 
-    # Configure options for the "dir" type, so we can customize some behaviour
-    # per path.
-    diropts:
-      "/":
-        # Enable listing when index.html is not present?
-        listing:
-          "/": false
-          "/pub/": true
-
-        # Exclude files matching these regular expressions. They won't appear
-        # in listings, and won't be served to users (404 will be returned
-        # instead).
-        #exclude: [".*\\.secret", ".*/config"]
-
-      #"/other":
-      #  listing: true
-
     # Enable request logging. The target is a log name, which should match an
     # entry in the top-level reqlog configuration (see above).
     reqlog:
@@ -102,9 +90,11 @@ https:
     certs: "/etc/letsencrypt/live/"
 
     # The rest of the fields are the same as for http above.
-    proxy:
-      "/": "http://localhost:8080/"
-      "/local/": "http://localhost:99/"
+    routes:
+      "/":
+        proxy: "http://localhost:8080/"
+      "/local/":
+        proxy: "http://localhost:99/"
 
 
 # Raw socket proxying.
diff --git a/go.mod b/go.mod
index 4008814..67d4861 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.14
 require (
 	blitiri.com.ar/go/log v1.1.0
 	blitiri.com.ar/go/systemd v1.1.0
+	github.com/google/go-cmp v0.4.1
 	golang.org/x/net v0.0.0-20200528225125-3c3fba18258b
 	gopkg.in/yaml.v3 v3.0.0-20200601152816-913338de1bd2
 )
diff --git a/go.sum b/go.sum
index 4fae164..629bf2d 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ blitiri.com.ar/go/log v1.1.0 h1:prKXp2hnYXRamcrYaCajq1SdQYvHU852lY7QStHyuaw=
 blitiri.com.ar/go/log v1.1.0/go.mod h1:CobnZ0FcxCAWHnkPCVtNPmj8AGiW9aNLKd/E7tI43Sw=
 blitiri.com.ar/go/systemd v1.1.0 h1:AMr7Ce/5CkvLZvGxsn/ZOagzFf3zU13rcgWdlbWMQ+Y=
 blitiri.com.ar/go/systemd v1.1.0/go.mod h1:0D9Ttrh+TX+WuKQ/dJpdhFND7NYy505v6jhsWrihmPY=
+github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0=
+github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/net v0.0.0-20200528225125-3c3fba18258b h1:IYiJPiJfzktmDAO1HQiwjMjwjlYKHAL7KzeD544RJPs=
 golang.org/x/net v0.0.0-20200528225125-3c3fba18258b/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
@@ -9,6 +11,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
 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=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200601152816-913338de1bd2 h1:VEmvx0P+GVTgkNu2EdTN988YCZPcD3lo9AoczZpucwc=
diff --git a/server/http.go b/server/http.go
index b513328..6aad341 100644
--- a/server/http.go
+++ b/server/http.go
@@ -34,39 +34,32 @@ func httpServer(addr string, conf config.HTTP) *http.Server {
 		ErrorLog: golog.New(ev, "", golog.Lshortfile),
 	}
 
-	// Load route table.
 	mux := http.NewServeMux()
 	srv.Handler = mux
 
-	routes := []struct {
-		name        string
-		table       map[string]string
-		makeHandler func(string, url.URL, *config.HTTP) http.Handler
-	}{
-		{"proxy", conf.Proxy, makeProxy},
-		{"dir", conf.Dir, makeDir},
-		{"file", conf.File, makeFile},
-		{"redirect", conf.Redirect, makeRedirect},
-		{"cgi", conf.CGI, makeCGI},
-	}
-	for _, r := range routes {
-		for from, to := range r.table {
-			toURL, err := url.Parse(to)
-			if err != nil {
-				log.Fatalf(
-					"route %s %q -> %q: destination is not a valid URL: %v",
-					r.name, from, to, err)
-			}
-			log.Infof("%s route %q -> %s %q", srv.Addr, from, r.name, toURL)
-			mux.Handle(from, r.makeHandler(from, *toURL, &conf))
+	// Load route table.
+	for path, r := range conf.Routes {
+		if r.Dir != "" {
+			log.Infof("%s route %q -> dir %q", srv.Addr, path, r.Dir)
+			mux.Handle(path, makeDir(path, r.Dir, r.DirOpts))
+		} else if r.File != "" {
+			log.Infof("%s route %q -> file %q", srv.Addr, path, r.File)
+			mux.Handle(path, makeFile(path, r.File))
+		} else if r.Proxy != nil {
+			log.Infof("%s route %q -> proxy %s", srv.Addr, path, r.Proxy)
+			mux.Handle(path, makeProxy(path, r.Proxy.URL()))
+		} else if r.Redirect != nil {
+			log.Infof("%s route %q -> redirect %s", srv.Addr, path, r.Redirect)
+			mux.Handle(path, makeRedirect(path, r.Redirect.URL()))
+		} else if len(r.CGI) > 0 {
+			log.Infof("%s route %q -> cgi %q", srv.Addr, path, r.CGI)
+			mux.Handle(path, makeCGI(path, r.CGI))
+		} else if r.Status > 0 {
+			log.Infof("%s route %q -> status %d", srv.Addr, path, r.Status)
+			mux.Handle(path, makeStatus(path, r.Status))
 		}
 	}
 
-	for from, status := range conf.Status {
-		log.Infof("%s route %s -> status %d", srv.Addr, from, status)
-		mux.Handle(from, makeStatus(from, status))
-	}
-
 	// Wrap the authentication handlers.
 	if len(conf.Auth) > 0 {
 		authMux := http.NewServeMux()
@@ -235,46 +228,40 @@ func pathOrOpaque(u url.URL) string {
 	return u.Opaque
 }
 
-func makeDir(from string, to url.URL, conf *config.HTTP) http.Handler {
-	path := pathOrOpaque(to)
-	fs := http.FileServer(NewFS(http.Dir(path), conf.DirOpts[from]))
+func makeDir(path string, dir string, opts config.DirOpts) http.Handler {
+	fs := http.FileServer(NewFS(http.Dir(dir), opts))
 
-	from = stripDomain(from)
+	path = stripDomain(path)
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		tr, _ := trace.FromContext(r.Context())
-		tr.Printf("serving dir root %q", path)
+		tr.Printf("serving dir root %q", dir)
 
-		r.URL.Path = strings.TrimPrefix(r.URL.Path, from)
+		r.URL.Path = strings.TrimPrefix(r.URL.Path, path)
 		if r.URL.Path == "" || r.URL.Path[0] != '/' {
 			r.URL.Path = "/" + r.URL.Path
 		}
-		tr.Printf("adjusted path: %q", r.URL.Path)
+		tr.Printf("adjusted dir: %q", r.URL.Path)
 		fs.ServeHTTP(w, r)
 	})
 }
 
-func makeFile(from string, to url.URL, conf *config.HTTP) http.Handler {
-	path := pathOrOpaque(to)
-
+func makeFile(path string, file string) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		tr, _ := trace.FromContext(r.Context())
-		tr.Printf("serving file %q", path)
-		http.ServeFile(w, r, path)
+		tr.Printf("serving file %q", file)
+		http.ServeFile(w, r, file)
 	})
 }
 
-func makeCGI(from string, to url.URL, conf *config.HTTP) http.Handler {
-	from = stripDomain(from)
-	path := pathOrOpaque(to)
-	args := queryToArgs(to.RawQuery)
-
+func makeCGI(path string, cmd []string) http.Handler {
+	path = stripDomain(path)
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		tr, _ := trace.FromContext(r.Context())
-		tr.Debugf("exec %q %q", path, args)
+		tr.Debugf("exec %q", cmd)
 		h := cgi.Handler{
-			Path:   path,
-			Args:   args,
-			Root:   from,
+			Path:   cmd[0],
+			Args:   cmd[1:],
+			Root:   path,
 			Logger: golog.New(tr, "", golog.Lshortfile),
 			Stderr: tr,
 		}
@@ -282,32 +269,14 @@ func makeCGI(from string, to url.URL, conf *config.HTTP) http.Handler {
 	})
 }
 
-func queryToArgs(query string) []string {
-	args := []string{}
-	for query != "" {
-		comp := query
-		if i := strings.IndexAny(comp, "&;"); i >= 0 {
-			comp, query = comp[:i], comp[i+1:]
-		} else {
-			query = ""
-		}
-
-		comp, _ = url.QueryUnescape(comp)
-		args = append(args, comp)
-
-	}
-
-	return args
-}
-
-func makeRedirect(from string, to url.URL, conf *config.HTTP) http.Handler {
-	from = stripDomain(from)
+func makeRedirect(path string, to url.URL) http.Handler {
+	path = stripDomain(path)
 
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		tr, _ := trace.FromContext(r.Context())
 		target := to
 		target.RawQuery = r.URL.RawQuery
-		target.Path = adjustPath(r.URL.Path, from, to.Path)
+		target.Path = adjustPath(r.URL.Path, path, to.Path)
 		tr.Printf("redirect to %q", target.String())
 
 		http.Redirect(w, r, target.String(), http.StatusTemporaryRedirect)
@@ -322,11 +291,11 @@ func makeStatus(from string, status int) http.Handler {
 	})
 }
 
-func makeProxy(from string, to url.URL, conf *config.HTTP) http.Handler {
+func makeProxy(path string, to url.URL) http.Handler {
 	proxy := &httputil.ReverseProxy{}
 	proxy.Transport = &proxyTransport{}
 
-	// Director that strips "from" from the request path, so that if we have
+	// Director that strips "path" from the request path, so that if we have
 	// this config:
 	//
 	//   /a/ -> http://dst/b
@@ -336,14 +305,14 @@ func makeProxy(from string, to url.URL, conf *config.HTTP) http.Handler {
 	//   /a/x  goes to  http://dst/b/x (not http://dst/b/a/x)
 	//   www.example.com/p/x  goes to  http://dst/q/x
 
-	// Strip the domain from `from`, if any. That is useful for the http
+	// Strip the domain from `path`, if any. That is useful for the http
 	// router, but to us is irrelevant.
-	from = stripDomain(from)
+	path = stripDomain(path)
 
 	proxy.Director = func(req *http.Request) {
 		req.URL.Scheme = to.Scheme
 		req.URL.Host = to.Host
-		req.URL.Path = adjustPath(req.URL.Path, from, to.Path)
+		req.URL.Path = adjustPath(req.URL.Path, path, to.Path)
 
 		// If the user agent is not set, prevent a fall back to the default value.
 		if _, ok := req.Header["User-Agent"]; !ok {
diff --git a/server/server_test.go b/server/server_test.go
index fd734d3..4a799c4 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -78,15 +78,12 @@ raw:
 
 http:
   "$HTTP_ADDR":
-    proxy:
-      "/be/": "$BACKEND_URL"
-      "localhost/xy/": "$BACKEND_URL"
-    file:
-      "/static/hola": "$PWD/testdata/hola"
-    dir:
-      "/dir/": "$PWD/testdata/"
-    redirect:
-      "/redir/": "http://$HTTP_ADDR/dir/"
+    routes:
+      "/be/": { proxy: "$BACKEND_URL" }
+      "localhost/xy/": { proxy: "$BACKEND_URL" }
+      "/static/hola": { file: "$PWD/testdata/hola" }
+      "/dir/": { dir: "$PWD/testdata/" }
+      "/redir/": { redirect: "http://$HTTP_ADDR/dir/" }
 `
 	configStr := strings.NewReplacer(
 		"$RAW_ADDR", rawAddr,
diff --git a/test/01-be.yaml b/test/01-be.yaml
index 8feebc3..39a0bf0 100644
--- a/test/01-be.yaml
+++ b/test/01-be.yaml
@@ -8,25 +8,31 @@ reqlog:
 
 http:
   ":8450":
-    dir:
-      "/dir/": "testdata/dir"
-      "/authdir/": "testdata/dir"
-    file:
-      "/file": "testdata/file"
-      "/file/second": "testdata/dir/ñaca"
-    cgi:
-      "/cgi/": "testdata/cgi.sh?param 1;param 2"
-
-    status:
-      "/status/543": 543
-
-    diropts:
+
+    routes:
       "/dir/":
-        listing:
-          "/": true
-          "/withindex/": false
-          "/withoutindex/": false
-        exclude: ["/ignored\\..*"]
+        dir: "testdata/dir"
+        diropts:
+          listing:
+            "/": true
+            "/withindex/": false
+            "/withoutindex/": false
+          exclude: ["/ignored\\..*"]
+
+      "/authdir/":
+        dir: "testdata/dir"
+
+      "/file":
+        file: "testdata/file"
+
+      "/file/second":
+        file: "testdata/dir/ñaca"
+
+      "/cgi/":
+        cgi: ["testdata/cgi.sh", "param 1", "param 2"]
+
+      "/status/543":
+        status: 543
 
     auth:
       "/authdir/ñaca": "testdata/authdb.yaml"
diff --git a/test/01-fe.yaml b/test/01-fe.yaml
index 48281de..1721c11 100644
--- a/test/01-fe.yaml
+++ b/test/01-fe.yaml
@@ -1,18 +1,25 @@
 
 control_addr: "127.0.0.1:8440"
 
-_proxy: &proxyroutes
-  "/dir/": "http://localhost:8450/dir/"
-  "/authdir/": "http://localhost:8450/authdir/"
-  "/file": "http://localhost:8450/file"
-  "/cgi/": "http://localhost:8450/cgi/"
-  "/status/": "http://localhost:8450/status/"
-  "/bad/unreacheable": "http://localhost:1/"
-  "/bad/empty": "http:"
-  "/dar/": "http://localhost:8450/dir/"
-
-_redirect: &redirect
-  "/gogo/": "https://google.com"
+_routes: &routes
+  "/dir/":
+    proxy: "http://localhost:8450/dir/"
+  "/authdir/":
+    proxy: "http://localhost:8450/authdir/"
+  "/file":
+    proxy: "http://localhost:8450/file"
+  "/cgi/":
+    proxy: "http://localhost:8450/cgi/"
+  "/status/":
+    proxy: "http://localhost:8450/status/"
+  "/bad/unreacheable":
+    proxy: "http://localhost:1/"
+  "/bad/empty":
+    proxy: "http:"
+  "/dar/":
+    proxy: "http://localhost:8450/dir/"
+  "/gogo/":
+    redirect: "https://google.com"
 
 reqlog:
   "requests":
@@ -20,16 +27,14 @@ reqlog:
 
 http:
   ":8441":
-    proxy: *proxyroutes
-    redirect: *redirect
+    routes: *routes
     reqlog:
       "/": "requests"
 
 https:
   ":8442":
     certs: ".certs"
-    proxy: *proxyroutes
-    redirect: *redirect
+    routes: *routes
     reqlog:
       "/": "requests"