From 429b1d85749b1ba13b6a291b9e9d518d68347ddb Mon Sep 17 00:00:00 2001 From: mpl Date: Wed, 19 Jun 2019 18:34:04 +0200 Subject: [PATCH] API: new contract Co-authored-by: Ludovic Fernandez --- cmd/traefik/traefik.go | 11 +- docs/content/operations/api.md | 168 ++++ docs/content/operations/debug-mode.md | 15 - .../reference/static-configuration/cli.txt | 9 +- .../reference/static-configuration/env.md | 8 +- .../reference/static-configuration/file.toml | 1 - docs/mkdocs.yml | 2 +- integration/simple_test.go | 8 +- integration/websocket_test.go | 20 +- pkg/anonymize/anonymize_config_test.go | 1 - pkg/api/handler.go | 381 +++++++- pkg/api/handler_test.go | 896 +++++++++++++++++- pkg/api/testdata/middleware-auth.json | 13 + pkg/api/testdata/middlewares-empty.json | 1 + pkg/api/testdata/middlewares-page2.json | 12 + pkg/api/testdata/middlewares.json | 35 + pkg/api/testdata/router-bar.json | 13 + pkg/api/testdata/routers-empty.json | 1 + pkg/api/testdata/routers-many-lastpage.json | 47 + pkg/api/testdata/routers-page2.json | 11 + pkg/api/testdata/routers.json | 28 + pkg/api/testdata/service-bar.json | 19 + pkg/api/testdata/services-empty.json | 1 + pkg/api/testdata/services-page2.json | 20 + pkg/api/testdata/services.json | 39 + pkg/api/testdata/tcprouter-bar.json | 9 + pkg/api/testdata/tcprouters-empty.json | 1 + pkg/api/testdata/tcprouters-page2.json | 11 + pkg/api/testdata/tcprouters.json | 23 + pkg/api/testdata/tcpservice-bar.json | 15 + pkg/api/testdata/tcpservices-empty.json | 1 + pkg/api/testdata/tcpservices-page2.json | 16 + pkg/api/testdata/tcpservices.json | 31 + pkg/config/static/static_config.go | 4 +- 34 files changed, 1810 insertions(+), 61 deletions(-) create mode 100644 docs/content/operations/api.md delete mode 100644 docs/content/operations/debug-mode.md create mode 100644 pkg/api/testdata/middleware-auth.json create mode 100644 pkg/api/testdata/middlewares-empty.json create mode 100644 pkg/api/testdata/middlewares-page2.json create mode 100644 pkg/api/testdata/middlewares.json create mode 100644 pkg/api/testdata/router-bar.json create mode 100644 pkg/api/testdata/routers-empty.json create mode 100644 pkg/api/testdata/routers-many-lastpage.json create mode 100644 pkg/api/testdata/routers-page2.json create mode 100644 pkg/api/testdata/routers.json create mode 100644 pkg/api/testdata/service-bar.json create mode 100644 pkg/api/testdata/services-empty.json create mode 100644 pkg/api/testdata/services-page2.json create mode 100644 pkg/api/testdata/services.json create mode 100644 pkg/api/testdata/tcprouter-bar.json create mode 100644 pkg/api/testdata/tcprouters-empty.json create mode 100644 pkg/api/testdata/tcprouters-page2.json create mode 100644 pkg/api/testdata/tcprouters.json create mode 100644 pkg/api/testdata/tcpservice-bar.json create mode 100644 pkg/api/testdata/tcpservices-empty.json create mode 100644 pkg/api/testdata/tcpservices-page2.json create mode 100644 pkg/api/testdata/tcpservices.json diff --git a/cmd/traefik/traefik.go b/cmd/traefik/traefik.go index 0c0f768c5..d69677b05 100644 --- a/cmd/traefik/traefik.go +++ b/cmd/traefik/traefik.go @@ -204,16 +204,11 @@ func configureLogging(staticConfiguration *static.Configuration) { // an explicitly defined log level always has precedence. if none is // given and debug mode is disabled, the default is ERROR, and DEBUG // otherwise. - var levelStr string - if staticConfiguration.Log != nil { + levelStr := "error" + if staticConfiguration.Log != nil && staticConfiguration.Log.Level != "" { levelStr = strings.ToLower(staticConfiguration.Log.Level) } - if levelStr == "" { - levelStr = "error" - if staticConfiguration.Global.Debug { - levelStr = "debug" - } - } + level, err := logrus.ParseLevel(levelStr) if err != nil { log.WithoutContext().Errorf("Error getting level: %v", err) diff --git a/docs/content/operations/api.md b/docs/content/operations/api.md new file mode 100644 index 000000000..0bbf0a185 --- /dev/null +++ b/docs/content/operations/api.md @@ -0,0 +1,168 @@ +# API + +Traefik exposes a number of information through an API handler, such as the configuration of all routers, services, middlewares, etc. + +As with all features of Traefik, this handler can be enabled with the [static configuration](../getting-started/configuration-overview.md#the-static-configuration). + +## Security + +Enabling the API in production is not recommended, because it will expose all configuration elements, +including sensitive data. + +In production, it should be at least secured by authentication and authorizations. + +A good sane default (non exhaustive) set of recommendations +would be to apply the following protection mechanisms: + +* At the application level: + securing with middlewares such as [basic authentication](../middlewares/basicauth.md) or [white listing](../middlewares/ipwhitelist.md). + +* At the transport level: + NOT publicly exposing the API's port, + keeping it restricted to internal networks + (as in the [principle of least privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege), applied to networks). + +## Configuration + +To enable the API handler: + +```toml tab="File" +[api] +``` + +```bash tab="CLI" +--api +``` + +### `dashboard` + +_Optional, Default=true_ + +Enable the dashboard. More about the dashboard features [here](./dashboard.md). + +```toml tab="File" +[api] + dashboard = true +``` + +```bash tab="CLI" +--api.dashboard +``` + +### `entrypoint` + +_Optional, Default="traefik"_ + +The entry point that the API handler will be bound to. +The default ("traefik") is an internal entry point (which is always defined). + +```toml tab="File" +[api] + entrypoint = "web" +``` + +```bash tab="CLI" +--api.entrypoint="web" +``` + +### `middlewares` + +_Optional, Default=empty_ + +The list of [middlewares](../middlewares/overview.md) applied to the API handler. + +```toml tab="File" +[api] + middlewares = ["api-auth", "api-prefix"] +``` + +```bash tab="CLI" +--api.middlewares="api-auth,api-prefix" +``` + +### `debug` + +_Optional, Default=false_ + +Enable additional endpoints for debugging and profiling, served under `/debug/`. + +```toml tab="File" +[api] + debug = true +``` + +```bash tab="CLI" +--api.debug=true +``` + +## Endpoints + +All the following endpoints must be accessed with a `GET` HTTP request. + +| Path | Description | +|--------------------------------|-------------------------------------------------------------------------------------------| +| `/api/http/routers` | Lists all the HTTP routers information. | +| `/api/http/routers/{name}` | Returns the information of the HTTP router specified by `name`. | +| `/api/http/services` | Lists all the HTTP services information. | +| `/api/http/services/{name}` | Returns the information of the HTTP service specified by `name`. | +| `/api/http/middlewares` | Lists all the HTTP middlewares information. | +| `/api/http/middlewares/{name}` | Returns the information of the HTTP middleware specified by `name`. | +| `/api/tcp/routers` | Lists all the TCP routers information. | +| `/api/tcp/routers/{name}` | Returns the information of the TCP router specified by `name`. | +| `/api/tcp/services` | Lists all the TCP services information. | +| `/api/tcp/services/{name}` | Returns the information of the TCP service specified by `name`. | +| `/api/version` | Returns information about Traefik version. | +| `/debug/vars` | See the [expvar](https://golang.org/pkg/expvar/) Go documentation. | +| `/debug/pprof/` | See the [pprof Index](https://golang.org/pkg/net/http/pprof/#Index) Go documentation. | +| `/debug/pprof/cmdline` | See the [pprof Cmdline](https://golang.org/pkg/net/http/pprof/#Cmdline) Go documentation. | +| `/debug/pprof/profile` | See the [pprof Profile](https://golang.org/pkg/net/http/pprof/#Profile) Go documentation. | +| `/debug/pprof/symbol` | See the [pprof Symbol](https://golang.org/pkg/net/http/pprof/#Symbol) Go documentation. | +| `/debug/pprof/trace` | See the [pprof Trace](https://golang.org/pkg/net/http/pprof/#Trace) Go documentation. | + +## Common Configuration Use Cases + +### Address / Port + +You can define a custom address/port like this: + +```toml +[entryPoints] + [entryPoints.web] + address = ":80" + + [entryPoints.foo] + address = ":8082" + + [entryPoints.bar] + address = ":8083" + +[ping] +entryPoint = "foo" + +[api] +entryPoint = "bar" +``` + +In the above example, you would access a service at /foo, an api endpoint, or the health-check as follows: + +* Service: `http://hostname:80/foo` +* API: `http://hostname:8083/api/http/routers` +* Ping URL: `http://hostname:8082/ping` + +### Authentication + +To restrict access to the API handler, one can add authentication with the [basic auth middleware](../middlewares/basicauth.md). + +```toml +[api] + middlewares=["api-auth"] +``` + +```toml +[http.middlewares] + [http.middlewares.api-auth.basicauth] + users = [ + "test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/", + "test2:$apr1$d9hr9HBB$4HxwgUir3HP4EsggP/QNo0", + ] +``` diff --git a/docs/content/operations/debug-mode.md b/docs/content/operations/debug-mode.md deleted file mode 100644 index fcda56c5e..000000000 --- a/docs/content/operations/debug-mode.md +++ /dev/null @@ -1,15 +0,0 @@ -# The Debug Mode - -Getting More Information (Not For Production) -{: .subtitle } - -The debug mode will make Traefik be _extremely_ verbose in its logs, and is NOT intended for production purposes. - -## Configuration Example - -??? example "TOML -- Enabling the Debug Mode" - - ```toml - [Global] - debug = true - ``` \ No newline at end of file diff --git a/docs/content/reference/static-configuration/cli.txt b/docs/content/reference/static-configuration/cli.txt index 2d9d2cfb9..22f9c6326 100644 --- a/docs/content/reference/static-configuration/cli.txt +++ b/docs/content/reference/static-configuration/cli.txt @@ -1,3 +1,4 @@ + --accesslog (Default: "false") Access log settings. @@ -95,8 +96,11 @@ --api.dashboard (Default: "true") Activate dashboard. +--api.debug (Default: "false") + Enable additional endpoints for debugging and profiling. + --api.entrypoint (Default: "traefik") - EntryPoint. + The entry point that the API handler will be bound to. --api.middlewares (Default: "") Middleware list. @@ -153,9 +157,6 @@ --global.checknewversion (Default: "true") Periodically check if a new version has been released. ---global.debug (Default: "false") - Enable debug mode. - --global.sendanonymoususage Periodically send anonymous usage statistics. If the option is not specified, it will be enabled by default. diff --git a/docs/content/reference/static-configuration/env.md b/docs/content/reference/static-configuration/env.md index c61eff1ed..1b2d4075f 100644 --- a/docs/content/reference/static-configuration/env.md +++ b/docs/content/reference/static-configuration/env.md @@ -93,8 +93,11 @@ Enable api/dashboard. (Default: ```false```) `TRAEFIK_API_DASHBOARD`: Activate dashboard. (Default: ```true```) +`TRAEFIK_API_DEBUG`: +Enable additional endpoints for debugging and profiling. (Default: ```false```) + `TRAEFIK_API_ENTRYPOINT`: -EntryPoint. (Default: ```traefik```) +The entry point that the API handler will be bound to. (Default: ```traefik```) `TRAEFIK_API_MIDDLEWARES`: Middleware list. @@ -147,9 +150,6 @@ WriteTimeout is the maximum duration before timing out writes of the response. I `TRAEFIK_GLOBAL_CHECKNEWVERSION`: Periodically check if a new version has been released. (Default: ```false```) -`TRAEFIK_GLOBAL_DEBUG`: -Enable debug mode. (Default: ```false```) - `TRAEFIK_GLOBAL_SENDANONYMOUSUSAGE`: Periodically send anonymous usage statistics. If the option is not specified, it will be enabled by default. diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index 5cfdf001c..05c269221 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -1,5 +1,4 @@ [Global] - Debug = true CheckNewVersion = true SendAnonymousUsage = true diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 03c97ce66..4a7a40cd1 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -113,8 +113,8 @@ nav: - 'Operations': - 'CLI': 'operations/cli.md' - 'Dashboard' : 'operations/dashboard.md' + - 'API': 'operations/api.md' - 'Ping': 'operations/ping.md' - - 'Debug Mode': 'operations/debug-mode.md' - 'Observability': - 'Logs': 'observability/logs.md' - 'Access Logs': 'observability/access-logs.md' diff --git a/integration/simple_test.go b/integration/simple_test.go index d02c9c621..335b493f6 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -161,7 +161,7 @@ func (s *SimpleSuite) TestApiOnSameEntryPoint(c *check.C) { s.createComposeProject(c, "base") s.composeProject.Start(c) - cmd, output := s.traefikCmd("--entryPoints.http.Address=:8000", "--api.entryPoint=http", "--global.debug", "--providers.docker") + cmd, output := s.traefikCmd("--entryPoints.http.Address=:8000", "--api.entryPoint=http", "--log.level=DEBUG", "--providers.docker") defer output(c) err := cmd.Start() @@ -241,7 +241,7 @@ func (s *SimpleSuite) TestDefaultEntrypointHTTP(c *check.C) { s.createComposeProject(c, "base") s.composeProject.Start(c) - cmd, output := s.traefikCmd("--entryPoints.http.Address=:8000", "--global.debug", "--providers.docker", "--api") + cmd, output := s.traefikCmd("--entryPoints.http.Address=:8000", "--log.level=DEBUG", "--providers.docker", "--api") defer output(c) err := cmd.Start() @@ -259,7 +259,7 @@ func (s *SimpleSuite) TestWithUnexistingEntrypoint(c *check.C) { s.createComposeProject(c, "base") s.composeProject.Start(c) - cmd, output := s.traefikCmd("--entryPoints.http.Address=:8000", "--global.debug", "--providers.docker", "--api") + cmd, output := s.traefikCmd("--entryPoints.http.Address=:8000", "--log.level=DEBUG", "--providers.docker", "--api") defer output(c) err := cmd.Start() @@ -277,7 +277,7 @@ func (s *SimpleSuite) TestMetricsPrometheusDefaultEntrypoint(c *check.C) { s.createComposeProject(c, "base") s.composeProject.Start(c) - cmd, output := s.traefikCmd("--entryPoints.http.Address=:8000", "--api", "--metrics.prometheus.buckets=0.1,0.3,1.2,5.0", "--providers.docker", "--global.debug") + cmd, output := s.traefikCmd("--entryPoints.http.Address=:8000", "--api", "--metrics.prometheus.buckets=0.1,0.3,1.2,5.0", "--providers.docker", "--log.level=DEBUG") defer output(c) err := cmd.Start() diff --git a/integration/websocket_test.go b/integration/websocket_test.go index 6dfca08e3..ef83c88bf 100644 --- a/integration/websocket_test.go +++ b/integration/websocket_test.go @@ -49,7 +49,7 @@ func (s *WebsocketSuite) TestBase(c *check.C) { }) defer os.Remove(file) - cmd, display := s.traefikCmd(withConfigFile(file), "--global.debug") + cmd, display := s.traefikCmd(withConfigFile(file), "--log.level=DEBUG") defer display(c) err := cmd.Start() @@ -99,7 +99,7 @@ func (s *WebsocketSuite) TestWrongOrigin(c *check.C) { }) defer os.Remove(file) - cmd, display := s.traefikCmd(withConfigFile(file), "--global.debug") + cmd, display := s.traefikCmd(withConfigFile(file), "--log.level=DEBUG") defer display(c) err := cmd.Start() @@ -149,7 +149,7 @@ func (s *WebsocketSuite) TestOrigin(c *check.C) { }) defer os.Remove(file) - cmd, display := s.traefikCmd(withConfigFile(file), "--global.debug") + cmd, display := s.traefikCmd(withConfigFile(file), "--log.level=DEBUG") defer display(c) err := cmd.Start() @@ -210,7 +210,7 @@ func (s *WebsocketSuite) TestWrongOriginIgnoredByServer(c *check.C) { }) defer os.Remove(file) - cmd, display := s.traefikCmd(withConfigFile(file), "--global.debug") + cmd, display := s.traefikCmd(withConfigFile(file), "--log.level=DEBUG") defer display(c) err := cmd.Start() @@ -268,7 +268,7 @@ func (s *WebsocketSuite) TestSSLTermination(c *check.C) { }) defer os.Remove(file) - cmd, display := s.traefikCmd(withConfigFile(file), "--global.debug") + cmd, display := s.traefikCmd(withConfigFile(file), "--log.level=DEBUG") defer display(c) err := cmd.Start() @@ -331,7 +331,7 @@ func (s *WebsocketSuite) TestBasicAuth(c *check.C) { }) defer os.Remove(file) - cmd, display := s.traefikCmd(withConfigFile(file), "--global.debug") + cmd, display := s.traefikCmd(withConfigFile(file), "--log.level=DEBUG") defer display(c) err := cmd.Start() @@ -375,7 +375,7 @@ func (s *WebsocketSuite) TestSpecificResponseFromBackend(c *check.C) { }) defer os.Remove(file) - cmd, display := s.traefikCmd(withConfigFile(file), "--global.debug") + cmd, display := s.traefikCmd(withConfigFile(file), "--log.level=DEBUG") defer display(c) err := cmd.Start() @@ -421,7 +421,7 @@ func (s *WebsocketSuite) TestURLWithURLEncodedChar(c *check.C) { }) defer os.Remove(file) - cmd, display := s.traefikCmd(withConfigFile(file), "--global.debug") + cmd, display := s.traefikCmd(withConfigFile(file), "--log.level=DEBUG") defer display(c) err := cmd.Start() @@ -476,7 +476,7 @@ func (s *WebsocketSuite) TestSSLhttp2(c *check.C) { }) defer os.Remove(file) - cmd, display := s.traefikCmd(withConfigFile(file), "--global.debug", "--accesslog") + cmd, display := s.traefikCmd(withConfigFile(file), "--log.level=DEBUG", "--accesslog") defer display(c) err := cmd.Start() @@ -535,7 +535,7 @@ func (s *WebsocketSuite) TestHeaderAreForwared(c *check.C) { }) defer os.Remove(file) - cmd, display := s.traefikCmd(withConfigFile(file), "--global.debug") + cmd, display := s.traefikCmd(withConfigFile(file), "--log.level=DEBUG") defer display(c) err := cmd.Start() diff --git a/pkg/anonymize/anonymize_config_test.go b/pkg/anonymize/anonymize_config_test.go index 0f77f660a..ebf08d390 100644 --- a/pkg/anonymize/anonymize_config_test.go +++ b/pkg/anonymize/anonymize_config_test.go @@ -28,7 +28,6 @@ func TestDo_globalConfiguration(t *testing.T) { sendAnonymousUsage := true config.Global = &static.Global{ - Debug: true, CheckNewVersion: true, SendAnonymousUsage: &sendAnonymousUsage, } diff --git a/pkg/api/handler.go b/pkg/api/handler.go index 89ef75cb6..0041e5d5d 100644 --- a/pkg/api/handler.go +++ b/pkg/api/handler.go @@ -1,8 +1,12 @@ package api import ( - "io" + "encoding/json" + "fmt" "net/http" + "sort" + "strconv" + "strings" "github.com/containous/mux" "github.com/containous/traefik/pkg/config" @@ -11,14 +15,14 @@ import ( "github.com/containous/traefik/pkg/types" "github.com/containous/traefik/pkg/version" assetfs "github.com/elazarl/go-bindata-assetfs" - "github.com/unrolled/render" ) -var templateRenderer jsonRenderer = render.New(render.Options{Directory: "nowhere"}) +const ( + defaultPerPage = 100 + defaultPage = 1 +) -type jsonRenderer interface { - JSON(w io.Writer, status int, v interface{}) error -} +const nextPageHeader = "X-Next-Page" type serviceInfoRepresentation struct { *config.ServiceInfo @@ -34,6 +38,43 @@ type RunTimeRepresentation struct { TCPServices map[string]*config.TCPServiceInfo `json:"tcpServices,omitempty"` } +type routerRepresentation struct { + *config.RouterInfo + Name string `json:"name,omitempty"` + Provider string `json:"provider,omitempty"` +} + +type serviceRepresentation struct { + *config.ServiceInfo + ServerStatus map[string]string `json:"serverStatus,omitempty"` + Name string `json:"name,omitempty"` + Provider string `json:"provider,omitempty"` +} + +type middlewareRepresentation struct { + *config.MiddlewareInfo + Name string `json:"name,omitempty"` + Provider string `json:"provider,omitempty"` +} + +type tcpRouterRepresentation struct { + *config.TCPRouterInfo + Name string `json:"name,omitempty"` + Provider string `json:"provider,omitempty"` +} + +type tcpServiceRepresentation struct { + *config.TCPServiceInfo + Name string `json:"name,omitempty"` + Provider string `json:"provider,omitempty"` +} + +type pageInfo struct { + startIndex int + endIndex int + nextPage int +} + // Handler serves the configuration and status of Traefik on API endpoints. type Handler struct { dashboard bool @@ -59,7 +100,7 @@ func New(staticConfig static.Configuration, runtimeConfig *config.RuntimeConfigu statistics: staticConfig.API.Statistics, dashboardAssets: staticConfig.API.DashboardAssets, runtimeConfiguration: rConfig, - debug: staticConfig.Global.Debug, + debug: staticConfig.API.Debug, } } @@ -71,9 +112,21 @@ func (h Handler) Append(router *mux.Router) { router.Methods(http.MethodGet).Path("/api/rawdata").HandlerFunc(h.getRuntimeConfiguration) + router.Methods(http.MethodGet).Path("/api/http/routers").HandlerFunc(h.getRouters) + router.Methods(http.MethodGet).Path("/api/http/routers/{routerID}").HandlerFunc(h.getRouter) + router.Methods(http.MethodGet).Path("/api/http/services").HandlerFunc(h.getServices) + router.Methods(http.MethodGet).Path("/api/http/services/{serviceID}").HandlerFunc(h.getService) + router.Methods(http.MethodGet).Path("/api/http/middlewares").HandlerFunc(h.getMiddlewares) + router.Methods(http.MethodGet).Path("/api/http/middlewares/{middlewareID}").HandlerFunc(h.getMiddleware) + + router.Methods(http.MethodGet).Path("/api/tcp/routers").HandlerFunc(h.getTCPRouters) + router.Methods(http.MethodGet).Path("/api/tcp/routers/{routerID}").HandlerFunc(h.getTCPRouter) + router.Methods(http.MethodGet).Path("/api/tcp/services").HandlerFunc(h.getTCPServices) + router.Methods(http.MethodGet).Path("/api/tcp/services/{serviceID}").HandlerFunc(h.getTCPService) + // FIXME stats // health route - //router.Methods(http.MethodGet).Path("/health").HandlerFunc(p.getHealthHandler) + // router.Methods(http.MethodGet).Path("/health").HandlerFunc(p.getHealthHandler) version.Handler{}.Append(router) @@ -82,6 +135,268 @@ func (h Handler) Append(router *mux.Router) { } } +func (h Handler) getRouters(rw http.ResponseWriter, request *http.Request) { + results := make([]routerRepresentation, 0, len(h.runtimeConfiguration.Routers)) + + for name, rt := range h.runtimeConfiguration.Routers { + results = append(results, routerRepresentation{ + RouterInfo: rt, + Name: name, + Provider: getProviderName(name), + }) + } + + sort.Slice(results, func(i, j int) bool { + return results[i].Name < results[j].Name + }) + + pageInfo, err := pagination(request, len(results)) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + rw.Header().Set(nextPageHeader, strconv.Itoa(pageInfo.nextPage)) + + err = json.NewEncoder(rw).Encode(results[pageInfo.startIndex:pageInfo.endIndex]) + if err != nil { + log.FromContext(request.Context()).Error(err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h Handler) getRouter(rw http.ResponseWriter, request *http.Request) { + routerID := mux.Vars(request)["routerID"] + + router, ok := h.runtimeConfiguration.Routers[routerID] + if !ok { + http.NotFound(rw, request) + return + } + + result := routerRepresentation{ + RouterInfo: router, + Name: routerID, + Provider: getProviderName(routerID), + } + + err := json.NewEncoder(rw).Encode(result) + if err != nil { + log.FromContext(request.Context()).Error(err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h Handler) getServices(rw http.ResponseWriter, request *http.Request) { + results := make([]serviceRepresentation, 0, len(h.runtimeConfiguration.Services)) + + for name, si := range h.runtimeConfiguration.Services { + results = append(results, serviceRepresentation{ + ServiceInfo: si, + Name: name, + Provider: getProviderName(name), + ServerStatus: si.GetAllStatus(), + }) + } + + sort.Slice(results, func(i, j int) bool { + return results[i].Name < results[j].Name + }) + + pageInfo, err := pagination(request, len(results)) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + rw.Header().Set(nextPageHeader, strconv.Itoa(pageInfo.nextPage)) + + err = json.NewEncoder(rw).Encode(results[pageInfo.startIndex:pageInfo.endIndex]) + if err != nil { + log.FromContext(request.Context()).Error(err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h Handler) getService(rw http.ResponseWriter, request *http.Request) { + serviceID := mux.Vars(request)["serviceID"] + + service, ok := h.runtimeConfiguration.Services[serviceID] + if !ok { + http.NotFound(rw, request) + return + } + + result := serviceRepresentation{ + ServiceInfo: service, + Name: serviceID, + Provider: getProviderName(serviceID), + ServerStatus: service.GetAllStatus(), + } + + err := json.NewEncoder(rw).Encode(result) + if err != nil { + log.FromContext(request.Context()).Error(err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h Handler) getMiddlewares(rw http.ResponseWriter, request *http.Request) { + results := make([]middlewareRepresentation, 0, len(h.runtimeConfiguration.Middlewares)) + + for name, mi := range h.runtimeConfiguration.Middlewares { + results = append(results, middlewareRepresentation{ + MiddlewareInfo: mi, + Name: name, + Provider: getProviderName(name), + }) + } + + sort.Slice(results, func(i, j int) bool { + return results[i].Name < results[j].Name + }) + + pageInfo, err := pagination(request, len(results)) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + rw.Header().Set(nextPageHeader, strconv.Itoa(pageInfo.nextPage)) + + err = json.NewEncoder(rw).Encode(results[pageInfo.startIndex:pageInfo.endIndex]) + if err != nil { + log.FromContext(request.Context()).Error(err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h Handler) getMiddleware(rw http.ResponseWriter, request *http.Request) { + middlewareID := mux.Vars(request)["middlewareID"] + + middleware, ok := h.runtimeConfiguration.Middlewares[middlewareID] + if !ok { + http.NotFound(rw, request) + return + } + + result := middlewareRepresentation{ + MiddlewareInfo: middleware, + Name: middlewareID, + Provider: getProviderName(middlewareID), + } + + err := json.NewEncoder(rw).Encode(result) + if err != nil { + log.FromContext(request.Context()).Error(err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h Handler) getTCPRouters(rw http.ResponseWriter, request *http.Request) { + results := make([]tcpRouterRepresentation, 0, len(h.runtimeConfiguration.TCPRouters)) + + for name, rt := range h.runtimeConfiguration.TCPRouters { + results = append(results, tcpRouterRepresentation{ + TCPRouterInfo: rt, + Name: name, + Provider: getProviderName(name), + }) + } + + sort.Slice(results, func(i, j int) bool { + return results[i].Name < results[j].Name + }) + + pageInfo, err := pagination(request, len(results)) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + rw.Header().Set(nextPageHeader, strconv.Itoa(pageInfo.nextPage)) + + err = json.NewEncoder(rw).Encode(results[pageInfo.startIndex:pageInfo.endIndex]) + if err != nil { + log.FromContext(request.Context()).Error(err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h Handler) getTCPRouter(rw http.ResponseWriter, request *http.Request) { + routerID := mux.Vars(request)["routerID"] + + router, ok := h.runtimeConfiguration.TCPRouters[routerID] + if !ok { + http.NotFound(rw, request) + return + } + + result := tcpRouterRepresentation{ + TCPRouterInfo: router, + Name: routerID, + Provider: getProviderName(routerID), + } + + err := json.NewEncoder(rw).Encode(result) + if err != nil { + log.FromContext(request.Context()).Error(err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h Handler) getTCPServices(rw http.ResponseWriter, request *http.Request) { + results := make([]tcpServiceRepresentation, 0, len(h.runtimeConfiguration.TCPServices)) + + for name, si := range h.runtimeConfiguration.TCPServices { + results = append(results, tcpServiceRepresentation{ + TCPServiceInfo: si, + Name: name, + Provider: getProviderName(name), + }) + } + + sort.Slice(results, func(i, j int) bool { + return results[i].Name < results[j].Name + }) + + pageInfo, err := pagination(request, len(results)) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + rw.Header().Set(nextPageHeader, strconv.Itoa(pageInfo.nextPage)) + + err = json.NewEncoder(rw).Encode(results[pageInfo.startIndex:pageInfo.endIndex]) + if err != nil { + log.FromContext(request.Context()).Error(err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h Handler) getTCPService(rw http.ResponseWriter, request *http.Request) { + serviceID := mux.Vars(request)["serviceID"] + + service, ok := h.runtimeConfiguration.TCPServices[serviceID] + if !ok { + http.NotFound(rw, request) + return + } + + result := tcpServiceRepresentation{ + TCPServiceInfo: service, + Name: serviceID, + Provider: getProviderName(serviceID), + } + + err := json.NewEncoder(rw).Encode(result) + if err != nil { + log.FromContext(request.Context()).Error(err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + } +} + func (h Handler) getRuntimeConfiguration(rw http.ResponseWriter, request *http.Request) { siRepr := make(map[string]*serviceInfoRepresentation, len(h.runtimeConfiguration.Services)) for k, v := range h.runtimeConfiguration.Services { @@ -91,7 +406,7 @@ func (h Handler) getRuntimeConfiguration(rw http.ResponseWriter, request *http.R } } - rtRepr := RunTimeRepresentation{ + result := RunTimeRepresentation{ Routers: h.runtimeConfiguration.Routers, Middlewares: h.runtimeConfiguration.Middlewares, Services: siRepr, @@ -99,9 +414,55 @@ func (h Handler) getRuntimeConfiguration(rw http.ResponseWriter, request *http.R TCPServices: h.runtimeConfiguration.TCPServices, } - err := templateRenderer.JSON(rw, http.StatusOK, rtRepr) + err := json.NewEncoder(rw).Encode(result) if err != nil { log.FromContext(request.Context()).Error(err) http.Error(rw, err.Error(), http.StatusInternalServerError) } } + +func pagination(request *http.Request, max int) (pageInfo, error) { + perPage, err := getIntParam(request, "per_page", defaultPerPage) + if err != nil { + return pageInfo{}, err + } + + page, err := getIntParam(request, "page", defaultPage) + if err != nil { + return pageInfo{}, err + } + + startIndex := (page - 1) * perPage + if startIndex != 0 && startIndex >= max { + return pageInfo{}, fmt.Errorf("invalid request: page: %d, per_page: %d", page, perPage) + } + + endIndex := startIndex + perPage + if endIndex >= max { + endIndex = max + } + + nextPage := 1 + if page*perPage < max { + nextPage = page + 1 + } + + return pageInfo{startIndex: startIndex, endIndex: endIndex, nextPage: nextPage}, nil +} + +func getIntParam(request *http.Request, key string, defaultValue int) (int, error) { + raw := request.URL.Query().Get(key) + if raw == "" { + return defaultValue, nil + } + + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return 0, fmt.Errorf("invalid request: %s: %d", key, value) + } + return value, nil +} + +func getProviderName(id string) string { + return strings.SplitN(id, ".", 2)[0] +} diff --git a/pkg/api/handler_test.go b/pkg/api/handler_test.go index a6075ad4d..a947cf1db 100644 --- a/pkg/api/handler_test.go +++ b/pkg/api/handler_test.go @@ -3,9 +3,11 @@ package api import ( "encoding/json" "flag" + "fmt" "io/ioutil" "net/http" "net/http/httptest" + "strconv" "testing" "github.com/containous/mux" @@ -17,6 +19,882 @@ import ( var updateExpected = flag.Bool("update_expected", false, "Update expected files in testdata") +func TestHandlerTCP_API(t *testing.T) { + type expected struct { + statusCode int + nextPage string + jsonFile string + } + + testCases := []struct { + desc string + path string + conf config.RuntimeConfiguration + expected expected + }{ + { + desc: "all TCP routers, but no config", + path: "/api/tcp/routers", + conf: config.RuntimeConfiguration{}, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "1", + jsonFile: "testdata/tcprouters-empty.json", + }, + }, + { + desc: "all TCP routers", + path: "/api/tcp/routers", + conf: config.RuntimeConfiguration{ + TCPRouters: map[string]*config.TCPRouterInfo{ + "myprovider.test": { + TCPRouter: &config.TCPRouter{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar.other`)", + TLS: &config.RouterTCPTLSConfig{ + Passthrough: false, + }, + }, + }, + "myprovider.bar": { + TCPRouter: &config.TCPRouter{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar`)", + }, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "1", + jsonFile: "testdata/tcprouters.json", + }, + }, + { + desc: "all TCP routers, pagination, 1 res per page, want page 2", + path: "/api/tcp/routers?page=2&per_page=1", + conf: config.RuntimeConfiguration{ + TCPRouters: map[string]*config.TCPRouterInfo{ + "myprovider.bar": { + TCPRouter: &config.TCPRouter{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar`)", + }, + }, + "myprovider.baz": { + TCPRouter: &config.TCPRouter{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`toto.bar`)", + }, + }, + "myprovider.test": { + TCPRouter: &config.TCPRouter{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar.other`)", + }, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "3", + jsonFile: "testdata/tcprouters-page2.json", + }, + }, + { + desc: "one TCP router by id", + path: "/api/tcp/routers/myprovider.bar", + conf: config.RuntimeConfiguration{ + TCPRouters: map[string]*config.TCPRouterInfo{ + "myprovider.bar": { + TCPRouter: &config.TCPRouter{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar`)", + }, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + jsonFile: "testdata/tcprouter-bar.json", + }, + }, + { + desc: "one TCP router by id, that does not exist", + path: "/api/tcp/routers/myprovider.foo", + conf: config.RuntimeConfiguration{ + TCPRouters: map[string]*config.TCPRouterInfo{ + "myprovider.bar": { + TCPRouter: &config.TCPRouter{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar`)", + }, + }, + }, + }, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + { + desc: "one TCP router by id, but no config", + path: "/api/tcp/routers/myprovider.bar", + conf: config.RuntimeConfiguration{}, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + { + desc: "all tcp services, but no config", + path: "/api/tcp/services", + conf: config.RuntimeConfiguration{}, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "1", + jsonFile: "testdata/tcpservices-empty.json", + }, + }, + { + desc: "all tcp services", + path: "/api/tcp/services", + conf: config.RuntimeConfiguration{ + TCPServices: map[string]*config.TCPServiceInfo{ + "myprovider.bar": { + TCPService: &config.TCPService{ + LoadBalancer: &config.TCPLoadBalancerService{ + Servers: []config.TCPServer{ + { + Address: "127.0.0.1:2345", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo", "myprovider.test"}, + }, + "myprovider.baz": { + TCPService: &config.TCPService{ + LoadBalancer: &config.TCPLoadBalancerService{ + Servers: []config.TCPServer{ + { + Address: "127.0.0.2:2345", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo"}, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "1", + jsonFile: "testdata/tcpservices.json", + }, + }, + { + desc: "all tcp services, 1 res per page, want page 2", + path: "/api/tcp/services?page=2&per_page=1", + conf: config.RuntimeConfiguration{ + TCPServices: map[string]*config.TCPServiceInfo{ + "myprovider.bar": { + TCPService: &config.TCPService{ + LoadBalancer: &config.TCPLoadBalancerService{ + Servers: []config.TCPServer{ + { + Address: "127.0.0.1:2345", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo", "myprovider.test"}, + }, + "myprovider.baz": { + TCPService: &config.TCPService{ + LoadBalancer: &config.TCPLoadBalancerService{ + Servers: []config.TCPServer{ + { + Address: "127.0.0.2:2345", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo"}, + }, + "myprovider.test": { + TCPService: &config.TCPService{ + LoadBalancer: &config.TCPLoadBalancerService{ + Servers: []config.TCPServer{ + { + Address: "127.0.0.3:2345", + }, + }, + }, + }, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "3", + jsonFile: "testdata/tcpservices-page2.json", + }, + }, + { + desc: "one tcp service by id", + path: "/api/tcp/services/myprovider.bar", + conf: config.RuntimeConfiguration{ + TCPServices: map[string]*config.TCPServiceInfo{ + "myprovider.bar": { + TCPService: &config.TCPService{ + LoadBalancer: &config.TCPLoadBalancerService{ + Servers: []config.TCPServer{ + { + Address: "127.0.0.1:2345", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo", "myprovider.test"}, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + jsonFile: "testdata/tcpservice-bar.json", + }, + }, + { + desc: "one tcp service by id, that does not exist", + path: "/api/tcp/services/myprovider.nono", + conf: config.RuntimeConfiguration{ + TCPServices: map[string]*config.TCPServiceInfo{ + "myprovider.bar": { + TCPService: &config.TCPService{ + LoadBalancer: &config.TCPLoadBalancerService{ + Servers: []config.TCPServer{ + { + Address: "127.0.0.1:2345", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo", "myprovider.test"}, + }, + }, + }, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + { + desc: "one tcp service by id, but no config", + path: "/api/tcp/services/myprovider.foo", + conf: config.RuntimeConfiguration{}, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + rtConf := &test.conf + handler := New(static.Configuration{API: &static.API{}, Global: &static.Global{}}, rtConf) + router := mux.NewRouter() + handler.Append(router) + + server := httptest.NewServer(router) + + resp, err := http.DefaultClient.Get(server.URL + test.path) + require.NoError(t, err) + + assert.Equal(t, test.expected.nextPage, resp.Header.Get(nextPageHeader)) + + require.Equal(t, test.expected.statusCode, resp.StatusCode) + + if test.expected.jsonFile == "" { + return + } + + contents, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + err = resp.Body.Close() + require.NoError(t, err) + + if *updateExpected { + var results interface{} + err := json.Unmarshal(contents, &results) + require.NoError(t, err) + + newJSON, err := json.MarshalIndent(results, "", "\t") + require.NoError(t, err) + + err = ioutil.WriteFile(test.expected.jsonFile, newJSON, 0644) + require.NoError(t, err) + } + + data, err := ioutil.ReadFile(test.expected.jsonFile) + require.NoError(t, err) + assert.JSONEq(t, string(data), string(contents)) + }) + } +} + +func TestHandlerHTTP_API(t *testing.T) { + type expected struct { + statusCode int + nextPage string + jsonFile string + } + + testCases := []struct { + desc string + path string + conf config.RuntimeConfiguration + expected expected + }{ + { + desc: "all routers, but no config", + path: "/api/http/routers", + conf: config.RuntimeConfiguration{}, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "1", + jsonFile: "testdata/routers-empty.json", + }, + }, + { + desc: "all routers", + path: "/api/http/routers", + conf: config.RuntimeConfiguration{ + Routers: map[string]*config.RouterInfo{ + "myprovider.test": { + Router: &config.Router{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar.other`)", + Middlewares: []string{"addPrefixTest", "auth"}, + }, + }, + "myprovider.bar": { + Router: &config.Router{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar`)", + Middlewares: []string{"auth", "anotherprovider.addPrefixTest"}, + }, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "1", + jsonFile: "testdata/routers.json", + }, + }, + { + desc: "all routers, pagination, 1 res per page, want page 2", + path: "/api/http/routers?page=2&per_page=1", + conf: config.RuntimeConfiguration{ + Routers: map[string]*config.RouterInfo{ + "myprovider.bar": { + Router: &config.Router{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar`)", + Middlewares: []string{"auth", "anotherprovider.addPrefixTest"}, + }, + }, + "myprovider.baz": { + Router: &config.Router{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`toto.bar`)", + }, + }, + "myprovider.test": { + Router: &config.Router{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar.other`)", + Middlewares: []string{"addPrefixTest", "auth"}, + }, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "3", + jsonFile: "testdata/routers-page2.json", + }, + }, + { + desc: "all routers, pagination, 19 results overall, 7 res per page, want page 3", + path: "/api/http/routers?page=3&per_page=7", + conf: config.RuntimeConfiguration{ + Routers: generateHTTPRouters(19), + }, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "1", + jsonFile: "testdata/routers-many-lastpage.json", + }, + }, + { + desc: "all routers, pagination, 5 results overall, 10 res per page, want page 2", + path: "/api/http/routers?page=2&per_page=10", + conf: config.RuntimeConfiguration{ + Routers: generateHTTPRouters(5), + }, + expected: expected{ + statusCode: http.StatusBadRequest, + }, + }, + { + desc: "all routers, pagination, 10 results overall, 10 res per page, want page 2", + path: "/api/http/routers?page=2&per_page=10", + conf: config.RuntimeConfiguration{ + Routers: generateHTTPRouters(10), + }, + expected: expected{ + statusCode: http.StatusBadRequest, + }, + }, + { + desc: "one router by id", + path: "/api/http/routers/myprovider.bar", + conf: config.RuntimeConfiguration{ + Routers: map[string]*config.RouterInfo{ + "myprovider.bar": { + Router: &config.Router{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar`)", + Middlewares: []string{"auth", "anotherprovider.addPrefixTest"}, + }, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + jsonFile: "testdata/router-bar.json", + }, + }, + { + desc: "one router by id, that does not exist", + path: "/api/http/routers/myprovider.foo", + conf: config.RuntimeConfiguration{ + Routers: map[string]*config.RouterInfo{ + "myprovider.bar": { + Router: &config.Router{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar`)", + Middlewares: []string{"auth", "anotherprovider.addPrefixTest"}, + }, + }, + }, + }, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + { + desc: "one router by id, but no config", + path: "/api/http/routers/myprovider.foo", + conf: config.RuntimeConfiguration{}, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + { + desc: "all services, but no config", + path: "/api/http/services", + conf: config.RuntimeConfiguration{}, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "1", + jsonFile: "testdata/services-empty.json", + }, + }, + { + desc: "all services", + path: "/api/http/services", + conf: config.RuntimeConfiguration{ + Services: map[string]*config.ServiceInfo{ + "myprovider.bar": func() *config.ServiceInfo { + si := &config.ServiceInfo{ + Service: &config.Service{ + LoadBalancer: &config.LoadBalancerService{ + Servers: []config.Server{ + { + URL: "http://127.0.0.1", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo", "myprovider.test"}, + } + si.UpdateStatus("http://127.0.0.1", "UP") + return si + }(), + "myprovider.baz": func() *config.ServiceInfo { + si := &config.ServiceInfo{ + Service: &config.Service{ + LoadBalancer: &config.LoadBalancerService{ + Servers: []config.Server{ + { + URL: "http://127.0.0.2", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo"}, + } + si.UpdateStatus("http://127.0.0.2", "UP") + return si + }(), + }, + }, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "1", + jsonFile: "testdata/services.json", + }, + }, + { + desc: "all services, 1 res per page, want page 2", + path: "/api/http/services?page=2&per_page=1", + conf: config.RuntimeConfiguration{ + Services: map[string]*config.ServiceInfo{ + "myprovider.bar": func() *config.ServiceInfo { + si := &config.ServiceInfo{ + Service: &config.Service{ + LoadBalancer: &config.LoadBalancerService{ + Servers: []config.Server{ + { + URL: "http://127.0.0.1", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo", "myprovider.test"}, + } + si.UpdateStatus("http://127.0.0.1", "UP") + return si + }(), + "myprovider.baz": func() *config.ServiceInfo { + si := &config.ServiceInfo{ + Service: &config.Service{ + LoadBalancer: &config.LoadBalancerService{ + Servers: []config.Server{ + { + URL: "http://127.0.0.2", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo"}, + } + si.UpdateStatus("http://127.0.0.2", "UP") + return si + }(), + "myprovider.test": func() *config.ServiceInfo { + si := &config.ServiceInfo{ + Service: &config.Service{ + LoadBalancer: &config.LoadBalancerService{ + Servers: []config.Server{ + { + URL: "http://127.0.0.3", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo", "myprovider.test"}, + } + si.UpdateStatus("http://127.0.0.4", "UP") + return si + }(), + }, + }, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "3", + jsonFile: "testdata/services-page2.json", + }, + }, + { + desc: "one service by id", + path: "/api/http/services/myprovider.bar", + conf: config.RuntimeConfiguration{ + Services: map[string]*config.ServiceInfo{ + "myprovider.bar": func() *config.ServiceInfo { + si := &config.ServiceInfo{ + Service: &config.Service{ + LoadBalancer: &config.LoadBalancerService{ + Servers: []config.Server{ + { + URL: "http://127.0.0.1", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo", "myprovider.test"}, + } + si.UpdateStatus("http://127.0.0.1", "UP") + return si + }(), + }, + }, + expected: expected{ + statusCode: http.StatusOK, + jsonFile: "testdata/service-bar.json", + }, + }, + { + desc: "one service by id, that does not exist", + path: "/api/http/services/myprovider.nono", + conf: config.RuntimeConfiguration{ + Services: map[string]*config.ServiceInfo{ + "myprovider.bar": func() *config.ServiceInfo { + si := &config.ServiceInfo{ + Service: &config.Service{ + LoadBalancer: &config.LoadBalancerService{ + Servers: []config.Server{ + { + URL: "http://127.0.0.1", + }, + }, + }, + }, + UsedBy: []string{"myprovider.foo", "myprovider.test"}, + } + si.UpdateStatus("http://127.0.0.1", "UP") + return si + }(), + }, + }, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + { + desc: "one service by id, but no config", + path: "/api/http/services/myprovider.foo", + conf: config.RuntimeConfiguration{}, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + { + desc: "all middlewares, but no config", + path: "/api/http/middlewares", + conf: config.RuntimeConfiguration{}, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "1", + jsonFile: "testdata/middlewares-empty.json", + }, + }, + { + desc: "all middlewares", + path: "/api/http/middlewares", + conf: config.RuntimeConfiguration{ + Middlewares: map[string]*config.MiddlewareInfo{ + "myprovider.auth": { + Middleware: &config.Middleware{ + BasicAuth: &config.BasicAuth{ + Users: []string{"admin:admin"}, + }, + }, + UsedBy: []string{"myprovider.bar", "myprovider.test"}, + }, + "myprovider.addPrefixTest": { + Middleware: &config.Middleware{ + AddPrefix: &config.AddPrefix{ + Prefix: "/titi", + }, + }, + UsedBy: []string{"myprovider.test"}, + }, + "anotherprovider.addPrefixTest": { + Middleware: &config.Middleware{ + AddPrefix: &config.AddPrefix{ + Prefix: "/toto", + }, + }, + UsedBy: []string{"myprovider.bar"}, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "1", + jsonFile: "testdata/middlewares.json", + }, + }, + { + desc: "all middlewares, 1 res per page, want page 2", + path: "/api/http/middlewares?page=2&per_page=1", + conf: config.RuntimeConfiguration{ + Middlewares: map[string]*config.MiddlewareInfo{ + "myprovider.auth": { + Middleware: &config.Middleware{ + BasicAuth: &config.BasicAuth{ + Users: []string{"admin:admin"}, + }, + }, + UsedBy: []string{"myprovider.bar", "myprovider.test"}, + }, + "myprovider.addPrefixTest": { + Middleware: &config.Middleware{ + AddPrefix: &config.AddPrefix{ + Prefix: "/titi", + }, + }, + UsedBy: []string{"myprovider.test"}, + }, + "anotherprovider.addPrefixTest": { + Middleware: &config.Middleware{ + AddPrefix: &config.AddPrefix{ + Prefix: "/toto", + }, + }, + UsedBy: []string{"myprovider.bar"}, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + nextPage: "3", + jsonFile: "testdata/middlewares-page2.json", + }, + }, + { + desc: "one middleware by id", + path: "/api/http/middlewares/myprovider.auth", + conf: config.RuntimeConfiguration{ + Middlewares: map[string]*config.MiddlewareInfo{ + "myprovider.auth": { + Middleware: &config.Middleware{ + BasicAuth: &config.BasicAuth{ + Users: []string{"admin:admin"}, + }, + }, + UsedBy: []string{"myprovider.bar", "myprovider.test"}, + }, + "myprovider.addPrefixTest": { + Middleware: &config.Middleware{ + AddPrefix: &config.AddPrefix{ + Prefix: "/titi", + }, + }, + UsedBy: []string{"myprovider.test"}, + }, + "anotherprovider.addPrefixTest": { + Middleware: &config.Middleware{ + AddPrefix: &config.AddPrefix{ + Prefix: "/toto", + }, + }, + UsedBy: []string{"myprovider.bar"}, + }, + }, + }, + expected: expected{ + statusCode: http.StatusOK, + jsonFile: "testdata/middleware-auth.json", + }, + }, + { + desc: "one middleware by id, that does not exist", + path: "/api/http/middlewares/myprovider.foo", + conf: config.RuntimeConfiguration{ + Middlewares: map[string]*config.MiddlewareInfo{ + "myprovider.auth": { + Middleware: &config.Middleware{ + BasicAuth: &config.BasicAuth{ + Users: []string{"admin:admin"}, + }, + }, + UsedBy: []string{"myprovider.bar", "myprovider.test"}, + }, + }, + }, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + { + desc: "one middleware by id, but no config", + path: "/api/http/middlewares/myprovider.foo", + conf: config.RuntimeConfiguration{}, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + rtConf := &test.conf + handler := New(static.Configuration{API: &static.API{}, Global: &static.Global{}}, rtConf) + router := mux.NewRouter() + handler.Append(router) + + server := httptest.NewServer(router) + + resp, err := http.DefaultClient.Get(server.URL + test.path) + require.NoError(t, err) + + require.Equal(t, test.expected.statusCode, resp.StatusCode) + + assert.Equal(t, test.expected.nextPage, resp.Header.Get(nextPageHeader)) + + if test.expected.jsonFile == "" { + return + } + + contents, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + err = resp.Body.Close() + require.NoError(t, err) + + if *updateExpected { + var results interface{} + err := json.Unmarshal(contents, &results) + require.NoError(t, err) + + newJSON, err := json.MarshalIndent(results, "", "\t") + require.NoError(t, err) + + err = ioutil.WriteFile(test.expected.jsonFile, newJSON, 0644) + require.NoError(t, err) + } + + data, err := ioutil.ReadFile(test.expected.jsonFile) + require.NoError(t, err) + assert.JSONEq(t, string(data), string(contents)) + }) + } + +} + func TestHandler_Configuration(t *testing.T) { type expected struct { statusCode int @@ -130,11 +1008,13 @@ func TestHandler_Configuration(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() + // TODO: server status + rtConf := &test.conf + rtConf.PopulateUsedBy() handler := New(static.Configuration{API: &static.API{}, Global: &static.Global{}}, rtConf) router := mux.NewRouter() handler.Append(router) - rtConf.PopulateUsedBy() server := httptest.NewServer(router) @@ -170,3 +1050,17 @@ func TestHandler_Configuration(t *testing.T) { }) } } + +func generateHTTPRouters(nbRouters int) map[string]*config.RouterInfo { + routers := make(map[string]*config.RouterInfo, nbRouters) + for i := 0; i < nbRouters; i++ { + routers[fmt.Sprintf("myprovider.bar%2d", i)] = &config.RouterInfo{ + Router: &config.Router{ + EntryPoints: []string{"web"}, + Service: "myprovider.foo-service", + Rule: "Host(`foo.bar" + strconv.Itoa(i) + "`)", + }, + } + } + return routers +} diff --git a/pkg/api/testdata/middleware-auth.json b/pkg/api/testdata/middleware-auth.json new file mode 100644 index 000000000..cced470df --- /dev/null +++ b/pkg/api/testdata/middleware-auth.json @@ -0,0 +1,13 @@ +{ + "basicAuth": { + "users": [ + "admin:admin" + ] + }, + "name": "myprovider.auth", + "provider": "myprovider", + "usedBy": [ + "myprovider.bar", + "myprovider.test" + ] +} \ No newline at end of file diff --git a/pkg/api/testdata/middlewares-empty.json b/pkg/api/testdata/middlewares-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/pkg/api/testdata/middlewares-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/pkg/api/testdata/middlewares-page2.json b/pkg/api/testdata/middlewares-page2.json new file mode 100644 index 000000000..98e31ae9c --- /dev/null +++ b/pkg/api/testdata/middlewares-page2.json @@ -0,0 +1,12 @@ +[ + { + "addPrefix": { + "prefix": "/titi" + }, + "name": "myprovider.addPrefixTest", + "provider": "myprovider", + "usedBy": [ + "myprovider.test" + ] + } +] \ No newline at end of file diff --git a/pkg/api/testdata/middlewares.json b/pkg/api/testdata/middlewares.json new file mode 100644 index 000000000..4462b93d0 --- /dev/null +++ b/pkg/api/testdata/middlewares.json @@ -0,0 +1,35 @@ +[ + { + "addPrefix": { + "prefix": "/toto" + }, + "name": "anotherprovider.addPrefixTest", + "provider": "anotherprovider", + "usedBy": [ + "myprovider.bar" + ] + }, + { + "addPrefix": { + "prefix": "/titi" + }, + "name": "myprovider.addPrefixTest", + "provider": "myprovider", + "usedBy": [ + "myprovider.test" + ] + }, + { + "basicAuth": { + "users": [ + "admin:admin" + ] + }, + "name": "myprovider.auth", + "provider": "myprovider", + "usedBy": [ + "myprovider.bar", + "myprovider.test" + ] + } +] \ No newline at end of file diff --git a/pkg/api/testdata/router-bar.json b/pkg/api/testdata/router-bar.json new file mode 100644 index 000000000..a8218df2e --- /dev/null +++ b/pkg/api/testdata/router-bar.json @@ -0,0 +1,13 @@ +{ + "entryPoints": [ + "web" + ], + "middlewares": [ + "auth", + "anotherprovider.addPrefixTest" + ], + "name": "myprovider.bar", + "provider": "myprovider", + "rule": "Host(`foo.bar`)", + "service": "myprovider.foo-service" +} \ No newline at end of file diff --git a/pkg/api/testdata/routers-empty.json b/pkg/api/testdata/routers-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/pkg/api/testdata/routers-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/pkg/api/testdata/routers-many-lastpage.json b/pkg/api/testdata/routers-many-lastpage.json new file mode 100644 index 000000000..c2ebc6177 --- /dev/null +++ b/pkg/api/testdata/routers-many-lastpage.json @@ -0,0 +1,47 @@ +[ + { + "entryPoints": [ + "web" + ], + "name": "myprovider.bar14", + "provider": "myprovider", + "rule": "Host(`foo.bar14`)", + "service": "myprovider.foo-service" + }, + { + "entryPoints": [ + "web" + ], + "name": "myprovider.bar15", + "provider": "myprovider", + "rule": "Host(`foo.bar15`)", + "service": "myprovider.foo-service" + }, + { + "entryPoints": [ + "web" + ], + "name": "myprovider.bar16", + "provider": "myprovider", + "rule": "Host(`foo.bar16`)", + "service": "myprovider.foo-service" + }, + { + "entryPoints": [ + "web" + ], + "name": "myprovider.bar17", + "provider": "myprovider", + "rule": "Host(`foo.bar17`)", + "service": "myprovider.foo-service" + }, + { + "entryPoints": [ + "web" + ], + "name": "myprovider.bar18", + "provider": "myprovider", + "rule": "Host(`foo.bar18`)", + "service": "myprovider.foo-service" + } +] \ No newline at end of file diff --git a/pkg/api/testdata/routers-page2.json b/pkg/api/testdata/routers-page2.json new file mode 100644 index 000000000..f9c5600a2 --- /dev/null +++ b/pkg/api/testdata/routers-page2.json @@ -0,0 +1,11 @@ +[ + { + "entryPoints": [ + "web" + ], + "name": "myprovider.baz", + "provider": "myprovider", + "rule": "Host(`toto.bar`)", + "service": "myprovider.foo-service" + } +] \ No newline at end of file diff --git a/pkg/api/testdata/routers.json b/pkg/api/testdata/routers.json new file mode 100644 index 000000000..8b97ebce0 --- /dev/null +++ b/pkg/api/testdata/routers.json @@ -0,0 +1,28 @@ +[ + { + "entryPoints": [ + "web" + ], + "middlewares": [ + "auth", + "anotherprovider.addPrefixTest" + ], + "name": "myprovider.bar", + "provider": "myprovider", + "rule": "Host(`foo.bar`)", + "service": "myprovider.foo-service" + }, + { + "entryPoints": [ + "web" + ], + "middlewares": [ + "addPrefixTest", + "auth" + ], + "name": "myprovider.test", + "provider": "myprovider", + "rule": "Host(`foo.bar.other`)", + "service": "myprovider.foo-service" + } +] \ No newline at end of file diff --git a/pkg/api/testdata/service-bar.json b/pkg/api/testdata/service-bar.json new file mode 100644 index 000000000..3bf950630 --- /dev/null +++ b/pkg/api/testdata/service-bar.json @@ -0,0 +1,19 @@ +{ + "loadbalancer": { + "passHostHeader": false, + "servers": [ + { + "url": "http://127.0.0.1" + } + ] + }, + "name": "myprovider.bar", + "provider": "myprovider", + "serverStatus": { + "http://127.0.0.1": "UP" + }, + "usedBy": [ + "myprovider.foo", + "myprovider.test" + ] +} \ No newline at end of file diff --git a/pkg/api/testdata/services-empty.json b/pkg/api/testdata/services-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/pkg/api/testdata/services-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/pkg/api/testdata/services-page2.json b/pkg/api/testdata/services-page2.json new file mode 100644 index 000000000..a6428e052 --- /dev/null +++ b/pkg/api/testdata/services-page2.json @@ -0,0 +1,20 @@ +[ + { + "loadbalancer": { + "passHostHeader": false, + "servers": [ + { + "url": "http://127.0.0.2" + } + ] + }, + "name": "myprovider.baz", + "provider": "myprovider", + "serverStatus": { + "http://127.0.0.2": "UP" + }, + "usedBy": [ + "myprovider.foo" + ] + } +] \ No newline at end of file diff --git a/pkg/api/testdata/services.json b/pkg/api/testdata/services.json new file mode 100644 index 000000000..29a9b7831 --- /dev/null +++ b/pkg/api/testdata/services.json @@ -0,0 +1,39 @@ +[ + { + "loadbalancer": { + "passHostHeader": false, + "servers": [ + { + "url": "http://127.0.0.1" + } + ] + }, + "name": "myprovider.bar", + "provider": "myprovider", + "serverStatus": { + "http://127.0.0.1": "UP" + }, + "usedBy": [ + "myprovider.foo", + "myprovider.test" + ] + }, + { + "loadbalancer": { + "passHostHeader": false, + "servers": [ + { + "url": "http://127.0.0.2" + } + ] + }, + "name": "myprovider.baz", + "provider": "myprovider", + "serverStatus": { + "http://127.0.0.2": "UP" + }, + "usedBy": [ + "myprovider.foo" + ] + } +] \ No newline at end of file diff --git a/pkg/api/testdata/tcprouter-bar.json b/pkg/api/testdata/tcprouter-bar.json new file mode 100644 index 000000000..9e02b55a6 --- /dev/null +++ b/pkg/api/testdata/tcprouter-bar.json @@ -0,0 +1,9 @@ +{ + "entryPoints": [ + "web" + ], + "name": "myprovider.bar", + "provider": "myprovider", + "rule": "Host(`foo.bar`)", + "service": "myprovider.foo-service" +} \ No newline at end of file diff --git a/pkg/api/testdata/tcprouters-empty.json b/pkg/api/testdata/tcprouters-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/pkg/api/testdata/tcprouters-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/pkg/api/testdata/tcprouters-page2.json b/pkg/api/testdata/tcprouters-page2.json new file mode 100644 index 000000000..f9c5600a2 --- /dev/null +++ b/pkg/api/testdata/tcprouters-page2.json @@ -0,0 +1,11 @@ +[ + { + "entryPoints": [ + "web" + ], + "name": "myprovider.baz", + "provider": "myprovider", + "rule": "Host(`toto.bar`)", + "service": "myprovider.foo-service" + } +] \ No newline at end of file diff --git a/pkg/api/testdata/tcprouters.json b/pkg/api/testdata/tcprouters.json new file mode 100644 index 000000000..54ee2ae26 --- /dev/null +++ b/pkg/api/testdata/tcprouters.json @@ -0,0 +1,23 @@ +[ + { + "entryPoints": [ + "web" + ], + "name": "myprovider.bar", + "provider": "myprovider", + "rule": "Host(`foo.bar`)", + "service": "myprovider.foo-service" + }, + { + "entryPoints": [ + "web" + ], + "name": "myprovider.test", + "provider": "myprovider", + "rule": "Host(`foo.bar.other`)", + "service": "myprovider.foo-service", + "tls": { + "passthrough": false + } + } +] \ No newline at end of file diff --git a/pkg/api/testdata/tcpservice-bar.json b/pkg/api/testdata/tcpservice-bar.json new file mode 100644 index 000000000..fc13265be --- /dev/null +++ b/pkg/api/testdata/tcpservice-bar.json @@ -0,0 +1,15 @@ +{ + "loadbalancer": { + "servers": [ + { + "address": "127.0.0.1:2345" + } + ] + }, + "name": "myprovider.bar", + "provider": "myprovider", + "usedBy": [ + "myprovider.foo", + "myprovider.test" + ] +} \ No newline at end of file diff --git a/pkg/api/testdata/tcpservices-empty.json b/pkg/api/testdata/tcpservices-empty.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/pkg/api/testdata/tcpservices-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/pkg/api/testdata/tcpservices-page2.json b/pkg/api/testdata/tcpservices-page2.json new file mode 100644 index 000000000..982adb142 --- /dev/null +++ b/pkg/api/testdata/tcpservices-page2.json @@ -0,0 +1,16 @@ +[ + { + "loadbalancer": { + "servers": [ + { + "address": "127.0.0.2:2345" + } + ] + }, + "name": "myprovider.baz", + "provider": "myprovider", + "usedBy": [ + "myprovider.foo" + ] + } +] \ No newline at end of file diff --git a/pkg/api/testdata/tcpservices.json b/pkg/api/testdata/tcpservices.json new file mode 100644 index 000000000..842de1e51 --- /dev/null +++ b/pkg/api/testdata/tcpservices.json @@ -0,0 +1,31 @@ +[ + { + "loadbalancer": { + "servers": [ + { + "address": "127.0.0.1:2345" + } + ] + }, + "name": "myprovider.bar", + "provider": "myprovider", + "usedBy": [ + "myprovider.foo", + "myprovider.test" + ] + }, + { + "loadbalancer": { + "servers": [ + { + "address": "127.0.0.2:2345" + } + ] + }, + "name": "myprovider.baz", + "provider": "myprovider", + "usedBy": [ + "myprovider.foo" + ] + } +] \ No newline at end of file diff --git a/pkg/config/static/static_config.go b/pkg/config/static/static_config.go index 9f7b08476..11d50bbb0 100644 --- a/pkg/config/static/static_config.go +++ b/pkg/config/static/static_config.go @@ -66,7 +66,6 @@ type Configuration struct { // Global holds the global configuration. type Global struct { - Debug bool `description:"Enable debug mode." export:"true"` CheckNewVersion bool `description:"Periodically check if a new version has been released." export:"true"` SendAnonymousUsage *bool `description:"Periodically send anonymous usage statistics. If the option is not specified, it will be enabled by default." export:"true"` } @@ -81,8 +80,9 @@ type ServersTransport struct { // API holds the API configuration type API struct { - EntryPoint string `description:"EntryPoint." export:"true"` + EntryPoint string `description:"The entry point that the API handler will be bound to." export:"true"` Dashboard bool `description:"Activate dashboard." export:"true"` + Debug bool `description:"Enable additional endpoints for debugging and profiling." export:"true"` Statistics *types.Statistics `description:"Enable more detailed statistics." export:"true" label:"allowEmpty"` Middlewares []string `description:"Middleware list." export:"true"` DashboardAssets *assetfs.AssetFS `json:"-" label:"-"`