diff --git a/admin/api/manual.go b/admin/api/manual.go index 5413aed3e..1e22e32ad 100644 --- a/admin/api/manual.go +++ b/admin/api/manual.go @@ -17,6 +17,12 @@ type manual struct { } func (h *ManualHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // we need this for testing. + // under normal circumstances this is never nil + if registry.Default == nil { + return + } + switch r.Method { case "GET": value, version, err := registry.Default.ReadManual() diff --git a/admin/server.go b/admin/server.go index 3577ba727..470006eab 100644 --- a/admin/server.go +++ b/admin/server.go @@ -13,6 +13,7 @@ import ( // Server provides the HTTP server for the admin UI and API. type Server struct { + Access string Color string Title string Version string @@ -22,18 +23,35 @@ type Server struct { // ListenAndServe starts the admin server. func (s *Server) ListenAndServe(l config.Listen, tlscfg *tls.Config) error { - http.Handle("/api/config", &api.ConfigHandler{s.Cfg}) - http.Handle("/api/manual", &api.ManualHandler{}) - http.Handle("/api/routes", &api.RoutesHandler{}) - http.Handle("/api/version", &api.VersionHandler{s.Version}) - http.Handle("/manual", &ui.ManualHandler{Color: s.Color, Title: s.Title, Version: s.Version, Commands: s.Commands}) - http.Handle("/routes", &ui.RoutesHandler{Color: s.Color, Title: s.Title, Version: s.Version}) - http.HandleFunc("/logo.svg", ui.HandleLogo) - http.HandleFunc("/health", handleHealth) - http.Handle("/", http.RedirectHandler("/routes", http.StatusSeeOther)) - return proxy.ListenAndServeHTTP(l, nil, tlscfg) + return proxy.ListenAndServeHTTP(l, s.handler(), tlscfg) +} + +func (s *Server) handler() http.Handler { + mux := http.NewServeMux() + + switch s.Access { + case "ro": + mux.HandleFunc("/api/manual", forbidden) + mux.HandleFunc("/manual", forbidden) + case "rw": + mux.Handle("/api/manual", &api.ManualHandler{}) + mux.Handle("/manual", &ui.ManualHandler{Color: s.Color, Title: s.Title, Version: s.Version, Commands: s.Commands}) + } + + mux.Handle("/api/config", &api.ConfigHandler{s.Cfg}) + mux.Handle("/api/routes", &api.RoutesHandler{}) + mux.Handle("/api/version", &api.VersionHandler{s.Version}) + mux.Handle("/routes", &ui.RoutesHandler{Color: s.Color, Title: s.Title, Version: s.Version}) + mux.HandleFunc("/logo.svg", ui.HandleLogo) + mux.HandleFunc("/health", handleHealth) + mux.Handle("/", http.RedirectHandler("/routes", http.StatusSeeOther)) + return mux } func handleHealth(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "OK") } + +func forbidden(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Forbidden", http.StatusForbidden) +} diff --git a/admin/server_test.go b/admin/server_test.go new file mode 100644 index 000000000..e69afaef2 --- /dev/null +++ b/admin/server_test.go @@ -0,0 +1,64 @@ +package admin + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAdminServerAccess(t *testing.T) { + type test struct { + uri string + code int + } + + testAccess := func(access string, tests []test) { + srv := &Server{Access: access} + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + noRedirectClient := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + for _, tt := range tests { + t.Run(access+tt.uri, func(t *testing.T) { + resp, err := noRedirectClient.Get(ts.URL + tt.uri) + if err != nil { + t.Fatalf("got %v want nil", err) + } + if got, want := resp.StatusCode, tt.code; got != want { + t.Fatalf("got code %d want %d", got, want) + } + }) + } + } + + roTests := []test{ + {"/api/manual", 403}, + {"/api/config", 200}, + {"/api/routes", 200}, + {"/api/version", 200}, + {"/manual", 403}, + {"/routes", 200}, + {"/health", 200}, + {"/logo.svg", 200}, + {"/", 303}, + } + + rwTests := []test{ + {"/api/manual", 200}, + {"/api/config", 200}, + {"/api/routes", 200}, + {"/api/version", 200}, + {"/manual", 200}, + {"/routes", 200}, + {"/health", 200}, + {"/logo.svg", 200}, + {"/", 303}, + } + + testAccess("ro", roTests) + testAccess("rw", rwTests) +} diff --git a/config/config.go b/config/config.go index 8e6788e13..216a44ddc 100644 --- a/config/config.go +++ b/config/config.go @@ -42,6 +42,7 @@ type UI struct { Listen Listen Color string Title string + Access string } type Proxy struct { diff --git a/config/default.go b/config/default.go index faaa9d18c..8dba9de2f 100644 --- a/config/default.go +++ b/config/default.go @@ -69,6 +69,7 @@ var defaultConfig = &Config{ Addr: ":9998", Proto: "http", }, - Color: "light-green", + Color: "light-green", + Access: "rw", }, } diff --git a/config/load.go b/config/load.go index 604ec8750..be436ba07 100644 --- a/config/load.go +++ b/config/load.go @@ -166,6 +166,7 @@ func load(cmdline, environ, envprefix []string, props *properties.Properties) (c f.BoolVar(&cfg.Registry.Consul.CheckTLSSkipVerify, "registry.consul.register.checkTLSSkipVerify", defaultConfig.Registry.Consul.CheckTLSSkipVerify, "service check TLS verifcation") f.IntVar(&cfg.Runtime.GOGC, "runtime.gogc", defaultConfig.Runtime.GOGC, "sets runtime.GOGC") f.IntVar(&cfg.Runtime.GOMAXPROCS, "runtime.gomaxprocs", defaultConfig.Runtime.GOMAXPROCS, "sets runtime.GOMAXPROCS") + f.StringVar(&cfg.UI.Access, "ui.access", defaultConfig.UI.Access, "access mode, one of [ro, rw]") f.StringVar(&uiListenerValue, "ui.addr", defaultValues.UIListenerValue, "Address the UI/API is listening on") f.StringVar(&cfg.UI.Color, "ui.color", defaultConfig.UI.Color, "background color of the UI") f.StringVar(&cfg.UI.Title, "ui.title", defaultConfig.UI.Title, "optional title for the UI") @@ -228,6 +229,10 @@ func load(cmdline, environ, envprefix []string, props *properties.Properties) (c return nil, fmt.Errorf("invalid proxy.matcher: %s", cfg.Proxy.Matcher) } + if cfg.UI.Access != "ro" && cfg.UI.Access != "rw" { + return nil, fmt.Errorf("invalid ui.access: %s", cfg.UI.Access) + } + // handle deprecations deprecate := func(name, msg string) { if f.IsSet(name) { diff --git a/config/load_test.go b/config/load_test.go index 4a46aa0a6..006743da3 100644 --- a/config/load_test.go +++ b/config/load_test.go @@ -593,6 +593,20 @@ func TestLoad(t *testing.T) { return cfg }, }, + { + args: []string{"-ui.access", "ro"}, + cfg: func(cfg *Config) *Config { + cfg.UI.Access = "ro" + return cfg + }, + }, + { + args: []string{"-ui.access", "rw"}, + cfg: func(cfg *Config) *Config { + cfg.UI.Access = "rw" + return cfg + }, + }, { args: []string{"-ui.addr", "1.2.3.4:5555"}, cfg: func(cfg *Config) *Config { diff --git a/fabio.properties b/fabio.properties index 4bbf269d4..469fe3438 100644 --- a/fabio.properties +++ b/fabio.properties @@ -814,6 +814,16 @@ # runtime.gomaxprocs = -1 +# ui.access configures the access mode for the UI. +# +# ro: read-only access +# rw: read-write access +# +# The default is +# +# ui.access = rw + + # ui.addr configures the address the UI is listening on. # The listener uses the same syntax as proxy.addr but # supports only a single listener. To enable HTTPS diff --git a/main.go b/main.go index 9332b21fe..f04c41a22 100644 --- a/main.go +++ b/main.go @@ -207,11 +207,13 @@ func makeTLSConfig(l config.Listen) *tls.Config { } func startAdmin(cfg *config.Config) { + log.Printf("[INFO] Admin server access mode %q", cfg.UI.Access) log.Printf("[INFO] Admin server listening on %q", cfg.UI.Listen.Addr) go func() { l := cfg.UI.Listen tlscfg := makeTLSConfig(l) srv := &admin.Server{ + Access: cfg.UI.Access, Color: cfg.UI.Color, Title: cfg.UI.Title, Version: version,