author | Alberto Bertogli
<albertito@blitiri.com.ar> 2020-06-13 00:55:12 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2020-06-13 11:49:42 UTC |
parent | e7866d3c6d850c8ecaf1d4402572afe3379a1adb |
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"