From 4e67ea007c154c27825d12063a4a70fafea718ae Mon Sep 17 00:00:00 2001 From: Ivan Fraixedes Date: Fri, 1 Oct 2021 14:26:21 +0200 Subject: [PATCH] satellite/admin: Serve static UI assets Change the satellite Admin HTTP server for: * Embedding the UI assets into the Go binary. * Serve the UI assets from the embedded file system or from a specific directory path through a configuration flag, without requiring authentication but keeping the authentication verification for the API endpoints. * Add tests to verify that the UI assets are served without authentication. Change-Id: I9003ac96f1ec585a189b67fc1cb315905403d557 --- satellite/admin/server.go | 115 +++++++++++--------- satellite/admin/server_test.go | 40 ++++++- scripts/testdata/satellite-config.yaml.lock | 3 + 3 files changed, 105 insertions(+), 53 deletions(-) diff --git a/satellite/admin/server.go b/satellite/admin/server.go index 2e1e8e637..90f772b1d 100644 --- a/satellite/admin/server.go +++ b/satellite/admin/server.go @@ -7,7 +7,9 @@ package admin import ( "context" "crypto/subtle" + "embed" "errors" + "io/fs" "net" "net/http" "time" @@ -24,9 +26,13 @@ import ( "storj.io/storj/satellite/payments/stripecoinpayments" ) +//go:embed ui/public +var ui embed.FS + // Config defines configuration for debug server. type Config struct { - Address string `help:"admin peer http listening address" releaseDefault:"" devDefault:""` + Address string `help:"admin peer http listening address" releaseDefault:"" devDefault:""` + StaticDir string `help:"an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""` AuthorizationToken string `internal:"true"` } @@ -49,7 +55,6 @@ type Server struct { listener net.Listener server http.Server - mux *mux.Router db DB payments payments.Accounts @@ -63,7 +68,6 @@ func NewServer(log *zap.Logger, listener net.Listener, db DB, accounts payments. log: log, listener: listener, - mux: mux.NewRouter(), db: db, payments: accounts, @@ -71,59 +75,45 @@ func NewServer(log *zap.Logger, listener net.Listener, db DB, accounts payments. nowFn: time.Now, } - server.server.Handler = &protectedServer{ - allowedAuthorization: config.AuthorizationToken, - next: server.mux, - } + root := mux.NewRouter() + + api := root.PathPrefix("/api/").Subrouter() + api.Use(allowedAuthorization(config.AuthorizationToken)) // When adding new options, also update README.md - server.mux.HandleFunc("/api/users", server.addUser).Methods("POST") - server.mux.HandleFunc("/api/users/{useremail}", server.updateUser).Methods("PUT") - server.mux.HandleFunc("/api/users/{useremail}", server.userInfo).Methods("GET") - server.mux.HandleFunc("/api/users/{useremail}", server.deleteUser).Methods("DELETE") - server.mux.HandleFunc("/api/projects", server.addProject).Methods("POST") - server.mux.HandleFunc("/api/projects/{project}/usage", server.checkProjectUsage).Methods("GET") - server.mux.HandleFunc("/api/projects/{project}/limit", server.getProjectLimit).Methods("GET") - server.mux.HandleFunc("/api/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST") - server.mux.HandleFunc("/api/projects/{project}", server.getProject).Methods("GET") - server.mux.HandleFunc("/api/projects/{project}", server.renameProject).Methods("PUT") - server.mux.HandleFunc("/api/projects/{project}", server.deleteProject).Methods("DELETE") - server.mux.HandleFunc("/api/projects/{project}/apikeys", server.listAPIKeys).Methods("GET") - server.mux.HandleFunc("/api/projects/{project}/apikeys", server.addAPIKey).Methods("POST") - server.mux.HandleFunc("/api/projects/{project}/apikeys/{name}", server.deleteAPIKeyByName).Methods("DELETE") - server.mux.HandleFunc("/api/apikeys/{apikey}", server.deleteAPIKey).Methods("DELETE") + api.HandleFunc("/users", server.addUser).Methods("POST") + api.HandleFunc("/users/{useremail}", server.updateUser).Methods("PUT") + api.HandleFunc("/users/{useremail}", server.userInfo).Methods("GET") + api.HandleFunc("/users/{useremail}", server.deleteUser).Methods("DELETE") + api.HandleFunc("/projects", server.addProject).Methods("POST") + api.HandleFunc("/projects/{project}/usage", server.checkProjectUsage).Methods("GET") + api.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET") + api.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST") + api.HandleFunc("/projects/{project}", server.getProject).Methods("GET") + api.HandleFunc("/projects/{project}", server.renameProject).Methods("PUT") + api.HandleFunc("/projects/{project}", server.deleteProject).Methods("DELETE") + api.HandleFunc("/projects/{project}/apikeys", server.listAPIKeys).Methods("GET") + api.HandleFunc("/projects/{project}/apikeys", server.addAPIKey).Methods("POST") + api.HandleFunc("/projects/{project}/apikeys/{name}", server.deleteAPIKeyByName).Methods("DELETE") + api.HandleFunc("/apikeys/{apikey}", server.deleteAPIKey).Methods("DELETE") + // This handler must be the last one because it uses the root as prefix, + // otherwise will try to serve all the handlers set after this one. + if config.StaticDir == "" { + uiAssets, err := fs.Sub(ui, "ui/public") + if err != nil { + log.Error("invalid embbeded static assets directory, the Admin UI is not enabled") + } else { + root.PathPrefix("/").Handler(http.FileServer(http.FS(uiAssets))).Methods("GET") + } + } else { + root.PathPrefix("/").Handler(http.FileServer(http.Dir(config.StaticDir))).Methods("GET") + } + + server.server.Handler = root return server } -type protectedServer struct { - allowedAuthorization string - - next http.Handler -} - -func (server *protectedServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if server.allowedAuthorization == "" { - sendJSONError(w, "Authorization not enabled.", - "", http.StatusForbidden) - return - } - - equality := subtle.ConstantTimeCompare( - []byte(r.Header.Get("Authorization")), - []byte(server.allowedAuthorization), - ) - if equality != 1 { - sendJSONError(w, "Forbidden", - "", http.StatusForbidden) - return - } - - r.Header.Set("Cache-Control", "must-revalidate") - - server.next.ServeHTTP(w, r) -} - // Run starts the admin endpoint. func (server *Server) Run(ctx context.Context) error { if server.listener == nil { @@ -156,3 +146,28 @@ func (server *Server) SetNow(nowFn func() time.Time) { func (server *Server) Close() error { return Error.Wrap(server.server.Close()) } + +func allowedAuthorization(token string) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token == "" { + sendJSONError(w, "Authorization not enabled.", + "", http.StatusForbidden) + return + } + + equality := subtle.ConstantTimeCompare( + []byte(r.Header.Get("Authorization")), + []byte(token), + ) + if equality != 1 { + sendJSONError(w, "Forbidden", + "", http.StatusForbidden) + return + } + + r.Header.Set("Cache-Control", "must-revalidate") + next.ServeHTTP(w, r) + }) + } +} diff --git a/satellite/admin/server_test.go b/satellite/admin/server_test.go index 8609bf6fd..0463de245 100644 --- a/satellite/admin/server_test.go +++ b/satellite/admin/server_test.go @@ -29,9 +29,43 @@ func TestBasic(t *testing.T) { }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { sat := planet.Satellites[0] address := sat.Admin.Admin.Listener.Addr() + baseURL := "http://" + address.String() + + t.Run("UI", func(t *testing.T) { + t.Run("index.html", func(t *testing.T) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) + require.NoError(t, err) + + response, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, response.StatusCode) + + content, err := ioutil.ReadAll(response.Body) + require.NoError(t, response.Body.Close()) + require.NotEmpty(t, content) + require.Contains(t, string(content), "") + require.NoError(t, err) + }) + + t.Run("css", func(t *testing.T) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/global.css", nil) + require.NoError(t, err) + + response, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, response.StatusCode) + + content, err := ioutil.ReadAll(response.Body) + require.NoError(t, response.Body.Close()) + require.NotEmpty(t, content) + require.NoError(t, err) + }) + }) t.Run("NoAccess", func(t *testing.T) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+address.String(), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/projects/some-id", nil) require.NoError(t, err) response, err := http.DefaultClient.Do(req) @@ -47,7 +81,7 @@ func TestBasic(t *testing.T) { }) t.Run("WrongAccess", func(t *testing.T) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+address.String(), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/users/alice@storj.test", nil) require.NoError(t, err) req.Header.Set("Authorization", "wrong-key") @@ -64,7 +98,7 @@ func TestBasic(t *testing.T) { }) t.Run("WithAccess", func(t *testing.T) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+address.String(), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api", nil) require.NoError(t, err) req.Header.Set("Authorization", planet.Satellites[0].Config.Console.AuthToken) diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock index 2dd7665f2..bafff85bf 100755 --- a/scripts/testdata/satellite-config.yaml.lock +++ b/scripts/testdata/satellite-config.yaml.lock @@ -1,6 +1,9 @@ # admin peer http listening address # admin.address: "" +# an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets +# admin.static-dir: "" + # enable analytics reporting # analytics.enabled: false