diff --git a/docs/content/operations/dashboard.md b/docs/content/operations/dashboard.md index c2b3c21e9..b7df89025 100644 --- a/docs/content/operations/dashboard.md +++ b/docs/content/operations/dashboard.md @@ -87,8 +87,44 @@ rule = "Host(`traefik.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/dashb ??? example "Dashboard Dynamic Configuration Examples" --8<-- "content/operations/include-dashboard-examples.md" +### Custom API Base Path + +As shown above, by default Traefik exposes its API and Dashboard under the `/` base path, +which means that respectively the API is served under the `/api` path, +and the dashboard under the `/dashboard` path. + +However, it is possible to configure this base path: + +```yaml tab="File (YAML)" +api: + # Customizes the base path: + # - Serving API under `/traefik/api` + # - Serving Dashboard under `/traefik/dashboard` + basePath: /traefik +``` + +```toml tab="File (TOML)" +[api] + # Customizes the base path: + # - Serving API under `/traefik/api` + # - Serving Dashboard under `/traefik/dashboard` + basePath = "/traefik" +``` + +```bash tab="CLI" +# Customizes the base path: +# - Serving API under `/traefik/api` +# - Serving Dashboard under `/traefik/dashboard` +--api.basePath=/traefik +``` + +??? example "Dashboard Under Custom Path Dynamic Configuration Examples" + --8<-- "content/operations/include-dashboard-custom-path-examples.md" + ## Insecure Mode +!!! warning "Please note that this mode is incompatible with the [custom API base path option](#custom-api-base-path)." + When _insecure_ mode is enabled, one can access the dashboard on the `traefik` port (default: `8080`) of the Traefik instance, at the following URL: `http://:8080/dashboard/` (trailing slash is mandatory). diff --git a/docs/content/operations/include-dashboard-custom-path-examples.md b/docs/content/operations/include-dashboard-custom-path-examples.md new file mode 100644 index 000000000..6767afc45 --- /dev/null +++ b/docs/content/operations/include-dashboard-custom-path-examples.md @@ -0,0 +1,83 @@ +```yaml tab="Docker & Swarm" +# Dynamic Configuration +labels: + - "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`) && PathPrefix(`/traefik`)" + - "traefik.http.routers.dashboard.service=api@internal" + - "traefik.http.routers.dashboard.middlewares=auth" + - "traefik.http.middlewares.auth.basicauth.users=test:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/,test2:$$apr1$$d9hr9HBB$$4HxwgUir3HP4EsggP/QNo0" +``` + +```yaml tab="Docker (Swarm)" +# Dynamic Configuration +deploy: + labels: + - "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`) && PathPrefix(`/traefik`)" + - "traefik.http.routers.dashboard.service=api@internal" + - "traefik.http.routers.dashboard.middlewares=auth" + - "traefik.http.middlewares.auth.basicauth.users=test:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/,test2:$$apr1$$d9hr9HBB$$4HxwgUir3HP4EsggP/QNo0" + # Dummy service for Swarm port detection. The port can be any valid integer value. + - "traefik.http.services.dummy-svc.loadbalancer.server.port=9999" +``` + +```yaml tab="Kubernetes CRD" +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: traefik-dashboard +spec: + routes: + - match: Host(`traefik.example.com`) && PathPrefix(`/traefik`) + kind: Rule + services: + - name: api@internal + kind: TraefikService + middlewares: + - name: auth +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: auth +spec: + basicAuth: + secret: secretName # Kubernetes secret named "secretName" +``` + +```yaml tab="Consul Catalog" +# Dynamic Configuration +- "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`) && PathPrefix(`/traefik`)" +- "traefik.http.routers.dashboard.service=api@internal" +- "traefik.http.routers.dashboard.middlewares=auth" +- "traefik.http.middlewares.auth.basicauth.users=test:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/,test2:$$apr1$$d9hr9HBB$$4HxwgUir3HP4EsggP/QNo0" +``` + +```yaml tab="File (YAML)" +# Dynamic Configuration +http: + routers: + dashboard: + rule: Host(`traefik.example.com`) && PathPrefix(`/traefik`) + service: api@internal + middlewares: + - auth + middlewares: + auth: + basicAuth: + users: + - "test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/" + - "test2:$apr1$d9hr9HBB$4HxwgUir3HP4EsggP/QNo0" +``` + +```toml tab="File (TOML)" +# Dynamic Configuration +[http.routers.my-api] + rule = "Host(`traefik.example.com`) && PathPrefix(`/traefik`)" + service = "api@internal" + middlewares = ["auth"] + +[http.middlewares.auth.basicAuth] + users = [ + "test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/", + "test2:$apr1$d9hr9HBB$4HxwgUir3HP4EsggP/QNo0", + ] +``` diff --git a/docs/content/reference/static-configuration/cli-ref.md b/docs/content/reference/static-configuration/cli-ref.md index 7e18a9b8b..25c5a57f1 100644 --- a/docs/content/reference/static-configuration/cli-ref.md +++ b/docs/content/reference/static-configuration/cli-ref.md @@ -42,6 +42,9 @@ Access log format: json | common (Default: ```common```) `--api`: Enable api/dashboard. (Default: ```false```) +`--api.basepath`: +Defines the base path where the API and Dashboard will be exposed. (Default: ```/```) + `--api.dashboard`: Activate dashboard. (Default: ```true```) diff --git a/docs/content/reference/static-configuration/env-ref.md b/docs/content/reference/static-configuration/env-ref.md index 61f0a28b0..362c34591 100644 --- a/docs/content/reference/static-configuration/env-ref.md +++ b/docs/content/reference/static-configuration/env-ref.md @@ -42,6 +42,9 @@ Access log format: json | common (Default: ```common```) `TRAEFIK_API`: Enable api/dashboard. (Default: ```false```) +`TRAEFIK_API_BASEPATH`: +Defines the base path where the API and Dashboard will be exposed. (Default: ```/```) + `TRAEFIK_API_DASHBOARD`: Activate dashboard. (Default: ```true```) diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index 9bfa02e1e..d0338aba1 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -294,6 +294,7 @@ name1 = "foobar" [api] + basePath = "foobar" insecure = true dashboard = true debug = true diff --git a/docs/content/reference/static-configuration/file.yaml b/docs/content/reference/static-configuration/file.yaml index 1fb8a7f30..b74bd5645 100644 --- a/docs/content/reference/static-configuration/file.yaml +++ b/docs/content/reference/static-configuration/file.yaml @@ -330,6 +330,7 @@ providers: name0: foobar name1: foobar api: + basePath: foobar insecure: true dashboard: true debug: true diff --git a/pkg/api/dashboard/dashboard.go b/pkg/api/dashboard/dashboard.go index a0ea9eb96..21eeee8fb 100644 --- a/pkg/api/dashboard/dashboard.go +++ b/pkg/api/dashboard/dashboard.go @@ -1,36 +1,88 @@ package dashboard import ( + "fmt" "io/fs" "net/http" "strings" + "text/template" "github.com/gorilla/mux" + "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/webui" ) +type indexTemplateData struct { + APIUrl string +} + // Handler expose dashboard routes. type Handler struct { + BasePath string + assets fs.FS // optional assets, to override the webui.FS default } +func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + assets := h.assets + if assets == nil { + assets = webui.FS + } + + // allow iframes from traefik domains only + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-src + w.Header().Set("Content-Security-Policy", "frame-src 'self' https://traefik.io https://*.traefik.io;") + + // The content type must be guessed by the file server. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + w.Header().Del("Content-Type") + + if r.RequestURI == "/" { + indexTemplate, err := template.ParseFS(assets, "index.html") + if err != nil { + log.Error().Err(err).Msg("Unable to parse index template") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + apiPath := strings.TrimSuffix(h.BasePath, "/") + "/api/" + if err = indexTemplate.Execute(w, indexTemplateData{APIUrl: apiPath}); err != nil { + log.Error().Err(err).Msg("Unable to render index template") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + return + } + + http.FileServerFS(assets).ServeHTTP(w, r) +} + // Append adds dashboard routes on the given router, optionally using the given // assets (or webui.FS otherwise). -func Append(router *mux.Router, customAssets fs.FS) { +func Append(router *mux.Router, basePath string, customAssets fs.FS) error { assets := customAssets if assets == nil { assets = webui.FS } + + indexTemplate, err := template.ParseFS(assets, "index.html") + if err != nil { + return fmt.Errorf("parsing index template: %w", err) + } + + dashboardPath := strings.TrimSuffix(basePath, "/") + "/dashboard/" + // Expose dashboard router.Methods(http.MethodGet). - Path("/"). + Path(basePath). HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { prefix := strings.TrimSuffix(req.Header.Get("X-Forwarded-Prefix"), "/") - http.Redirect(resp, req, prefix+"/dashboard/", http.StatusFound) + http.Redirect(resp, req, prefix+dashboardPath, http.StatusFound) }) router.Methods(http.MethodGet). - PathPrefix("/dashboard/"). + Path(dashboardPath). HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // allow iframes from our domains only // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-src @@ -40,22 +92,26 @@ func Append(router *mux.Router, customAssets fs.FS) { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options w.Header().Del("Content-Type") - http.StripPrefix("/dashboard/", http.FileServerFS(assets)).ServeHTTP(w, r) + apiPath := strings.TrimSuffix(basePath, "/") + "/api/" + if err = indexTemplate.Execute(w, indexTemplateData{APIUrl: apiPath}); err != nil { + log.Error().Err(err).Msg("Unable to render index template") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } }) -} - -func (g Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - assets := g.assets - if assets == nil { - assets = webui.FS - } - // allow iframes from our domains only - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-src - w.Header().Set("Content-Security-Policy", "frame-src 'self' https://traefik.io https://*.traefik.io;") - - // The content type must be guessed by the file server. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options - w.Header().Del("Content-Type") - - http.FileServerFS(assets).ServeHTTP(w, r) + + router.Methods(http.MethodGet). + PathPrefix(dashboardPath). + HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // allow iframes from traefik domains only + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-src + w.Header().Set("Content-Security-Policy", "frame-src 'self' https://traefik.io https://*.traefik.io;") + + // The content type must be guessed by the file server. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + w.Header().Del("Content-Type") + + http.StripPrefix(dashboardPath, http.FileServerFS(assets)).ServeHTTP(w, r) + }) + return nil } diff --git a/pkg/api/handler.go b/pkg/api/handler.go index b137e5401..d0f90150e 100644 --- a/pkg/api/handler.go +++ b/pkg/api/handler.go @@ -78,38 +78,40 @@ func New(staticConfig static.Configuration, runtimeConfig *runtime.Configuration func (h Handler) createRouter() *mux.Router { router := mux.NewRouter().UseEncodedPath() + apiRouter := router.PathPrefix(h.staticConfig.API.BasePath).Subrouter().UseEncodedPath() + if h.staticConfig.API.Debug { - DebugHandler{}.Append(router) + DebugHandler{}.Append(apiRouter) } - router.Methods(http.MethodGet).Path("/api/rawdata").HandlerFunc(h.getRuntimeConfiguration) + apiRouter.Methods(http.MethodGet).Path("/api/rawdata").HandlerFunc(h.getRuntimeConfiguration) // Experimental endpoint - router.Methods(http.MethodGet).Path("/api/overview").HandlerFunc(h.getOverview) + apiRouter.Methods(http.MethodGet).Path("/api/overview").HandlerFunc(h.getOverview) - router.Methods(http.MethodGet).Path("/api/entrypoints").HandlerFunc(h.getEntryPoints) - router.Methods(http.MethodGet).Path("/api/entrypoints/{entryPointID}").HandlerFunc(h.getEntryPoint) + apiRouter.Methods(http.MethodGet).Path("/api/entrypoints").HandlerFunc(h.getEntryPoints) + apiRouter.Methods(http.MethodGet).Path("/api/entrypoints/{entryPointID}").HandlerFunc(h.getEntryPoint) - 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) + apiRouter.Methods(http.MethodGet).Path("/api/http/routers").HandlerFunc(h.getRouters) + apiRouter.Methods(http.MethodGet).Path("/api/http/routers/{routerID}").HandlerFunc(h.getRouter) + apiRouter.Methods(http.MethodGet).Path("/api/http/services").HandlerFunc(h.getServices) + apiRouter.Methods(http.MethodGet).Path("/api/http/services/{serviceID}").HandlerFunc(h.getService) + apiRouter.Methods(http.MethodGet).Path("/api/http/middlewares").HandlerFunc(h.getMiddlewares) + apiRouter.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) - router.Methods(http.MethodGet).Path("/api/tcp/middlewares").HandlerFunc(h.getTCPMiddlewares) - router.Methods(http.MethodGet).Path("/api/tcp/middlewares/{middlewareID}").HandlerFunc(h.getTCPMiddleware) + apiRouter.Methods(http.MethodGet).Path("/api/tcp/routers").HandlerFunc(h.getTCPRouters) + apiRouter.Methods(http.MethodGet).Path("/api/tcp/routers/{routerID}").HandlerFunc(h.getTCPRouter) + apiRouter.Methods(http.MethodGet).Path("/api/tcp/services").HandlerFunc(h.getTCPServices) + apiRouter.Methods(http.MethodGet).Path("/api/tcp/services/{serviceID}").HandlerFunc(h.getTCPService) + apiRouter.Methods(http.MethodGet).Path("/api/tcp/middlewares").HandlerFunc(h.getTCPMiddlewares) + apiRouter.Methods(http.MethodGet).Path("/api/tcp/middlewares/{middlewareID}").HandlerFunc(h.getTCPMiddleware) - router.Methods(http.MethodGet).Path("/api/udp/routers").HandlerFunc(h.getUDPRouters) - router.Methods(http.MethodGet).Path("/api/udp/routers/{routerID}").HandlerFunc(h.getUDPRouter) - router.Methods(http.MethodGet).Path("/api/udp/services").HandlerFunc(h.getUDPServices) - router.Methods(http.MethodGet).Path("/api/udp/services/{serviceID}").HandlerFunc(h.getUDPService) + apiRouter.Methods(http.MethodGet).Path("/api/udp/routers").HandlerFunc(h.getUDPRouters) + apiRouter.Methods(http.MethodGet).Path("/api/udp/routers/{routerID}").HandlerFunc(h.getUDPRouter) + apiRouter.Methods(http.MethodGet).Path("/api/udp/services").HandlerFunc(h.getUDPServices) + apiRouter.Methods(http.MethodGet).Path("/api/udp/services/{serviceID}").HandlerFunc(h.getUDPService) - version.Handler{}.Append(router) + version.Handler{}.Append(apiRouter) return router } diff --git a/pkg/config/static/static_config.go b/pkg/config/static/static_config.go index 7f26e053a..6451bc77f 100644 --- a/pkg/config/static/static_config.go +++ b/pkg/config/static/static_config.go @@ -3,6 +3,7 @@ package static import ( "errors" "fmt" + "path" "strings" "time" @@ -145,16 +146,18 @@ type TLSClientConfig struct { // API holds the API configuration. type API struct { - Insecure bool `description:"Activate API directly on the entryPoint named traefik." json:"insecure,omitempty" toml:"insecure,omitempty" yaml:"insecure,omitempty" export:"true"` - Dashboard bool `description:"Activate dashboard." json:"dashboard,omitempty" toml:"dashboard,omitempty" yaml:"dashboard,omitempty" export:"true"` - Debug bool `description:"Enable additional endpoints for debugging and profiling." json:"debug,omitempty" toml:"debug,omitempty" yaml:"debug,omitempty" export:"true"` - DisableDashboardAd bool `description:"Disable ad in the dashboard." json:"disableDashboardAd,omitempty" toml:"disableDashboardAd,omitempty" yaml:"disableDashboardAd,omitempty" export:"true"` + BasePath string `description:"Defines the base path where the API and Dashboard will be exposed." json:"basePath,omitempty" toml:"basePath,omitempty" yaml:"basePath,omitempty" export:"true"` + Insecure bool `description:"Activate API directly on the entryPoint named traefik." json:"insecure,omitempty" toml:"insecure,omitempty" yaml:"insecure,omitempty" export:"true"` + Dashboard bool `description:"Activate dashboard." json:"dashboard,omitempty" toml:"dashboard,omitempty" yaml:"dashboard,omitempty" export:"true"` + Debug bool `description:"Enable additional endpoints for debugging and profiling." json:"debug,omitempty" toml:"debug,omitempty" yaml:"debug,omitempty" export:"true"` + DisableDashboardAd bool `description:"Disable ad in the dashboard." json:"disableDashboardAd,omitempty" toml:"disableDashboardAd,omitempty" yaml:"disableDashboardAd,omitempty" export:"true"` // TODO: Re-enable statistics // Statistics *types.Statistics `description:"Enable more detailed statistics." json:"statistics,omitempty" toml:"statistics,omitempty" yaml:"statistics,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"` } // SetDefaults sets the default values. func (a *API) SetDefaults() { + a.BasePath = "/" a.Dashboard = true } @@ -360,6 +363,10 @@ func (c *Configuration) ValidateConfiguration() error { } } + if c.API != nil && !path.IsAbs(c.API.BasePath) { + return errors.New("API basePath must be a valid absolute path") + } + return nil } diff --git a/pkg/server/service/managerfactory.go b/pkg/server/service/managerfactory.go index ce1fa8a27..19da05d7b 100644 --- a/pkg/server/service/managerfactory.go +++ b/pkg/server/service/managerfactory.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/gorilla/mux" + "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/api" "github.com/traefik/traefik/v3/pkg/api/dashboard" "github.com/traefik/traefik/v3/pkg/config/runtime" @@ -44,10 +45,13 @@ func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *s apiRouterBuilder := api.NewBuilder(staticConfiguration) if staticConfiguration.API.Dashboard { - factory.dashboardHandler = dashboard.Handler{} + factory.dashboardHandler = dashboard.Handler{BasePath: staticConfiguration.API.BasePath} factory.api = func(configuration *runtime.Configuration) http.Handler { router := apiRouterBuilder(configuration).(*mux.Router) - dashboard.Append(router, nil) + if err := dashboard.Append(router, staticConfiguration.API.BasePath, nil); err != nil { + log.Error().Err(err).Msg("Error appending dashboard to API router") + } + return router } } else { diff --git a/webui/index.html b/webui/index.html index 6eb6d6091..654437a27 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1,6 +1,13 @@ + + {{if .APIUrl}} + + {{end}} + <%= productName %> diff --git a/webui/src/boot/api.js b/webui/src/boot/api.js index 8d4f5b314..1d8f6deaf 100644 --- a/webui/src/boot/api.js +++ b/webui/src/boot/api.js @@ -4,7 +4,7 @@ import { APP } from '../_helpers/APP' // Set config defaults when creating the instance const api = axios.create({ - baseURL: APP.config.apiUrl + baseURL: window.APIURL || APP.config.apiUrl }) export default boot(({ app }) => {