satellite/console: optional separate web app server

This change creates the ability to run a server separate from the
console web server to serve the front end app. You can run it with
`satellite run ui`. Since there are now potentially two servers instead
of one, the UI server has the option to act as a reverse proxy to the
api server for local development by setting `--console.address` to the
console backend address and `--console.backend-reverse-proxy` to the
console backend's http url. Also, a feature flag has been implemented
on the api server to retain the ability to serve the front end app. It
is toggled with `--console.frontend-enable`.

github issue: https://github.com/storj/storj/issues/5843

Change-Id: I0d30451a20636e3184110dbe28c8a2a8a9505804
This commit is contained in:
Cameron 2023-05-17 14:18:54 -04:00
parent 9370bc4580
commit 7e03ccfa46
8 changed files with 396 additions and 28 deletions

View File

@ -100,6 +100,11 @@ var (
Short: "Run the satellite API",
RunE: cmdAPIRun,
}
runUICmd = &cobra.Command{
Use: "ui",
Short: "Run the satellite UI",
RunE: cmdUIRun,
}
runRepairerCmd = &cobra.Command{
Use: "repair",
Short: "Run the repair service",
@ -366,6 +371,7 @@ func init() {
rootCmd.AddCommand(runCmd)
runCmd.AddCommand(runMigrationCmd)
runCmd.AddCommand(runAPICmd)
runCmd.AddCommand(runUICmd)
runCmd.AddCommand(runAdminCmd)
runCmd.AddCommand(runRepairerCmd)
runCmd.AddCommand(runAuditorCmd)
@ -404,6 +410,7 @@ func init() {
process.Bind(runCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(runMigrationCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(runAPICmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(runUICmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(runAdminCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(runRepairerCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(runAuditorCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))

47
cmd/satellite/ui.go Normal file
View File

@ -0,0 +1,47 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
package main
import (
"github.com/spf13/cobra"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/private/process"
"storj.io/storj/satellite"
)
func cmdUIRun(cmd *cobra.Command, args []string) (err error) {
ctx, _ := process.Ctx(cmd)
log := zap.L()
runCfg.Debug.Address = *process.DebugAddrFlag
identity, err := runCfg.Identity.Load()
if err != nil {
log.Error("Failed to load identity.", zap.Error(err))
return errs.New("Failed to load identity: %+v", err)
}
satAddr := runCfg.Config.Contact.ExternalAddress
if satAddr == "" {
return errs.New("cannot run satellite ui if contact.external-address is not set")
}
apiAddress := runCfg.Config.Console.ExternalAddress
if apiAddress == "" {
apiAddress = runCfg.Config.Console.Address
}
peer, err := satellite.NewUI(log, identity, &runCfg.Config, process.AtomicLevel(cmd), satAddr, apiAddress)
if err != nil {
return err
}
if err := process.InitMetricsWithHostname(ctx, log, nil); err != nil {
log.Warn("Failed to initialize telemetry batcher on satellite api", zap.Error(err))
}
runError := peer.Run(ctx)
closeError := peer.Close()
return errs.Combine(runError, closeError)
}

View File

@ -66,6 +66,7 @@ type Satellite struct {
Core *satellite.Core
API *satellite.API
UI *satellite.UI
Repairer *satellite.Repairer
Auditor *satellite.Auditor
Admin *satellite.Admin
@ -172,12 +173,17 @@ type Satellite struct {
Service *mailservice.Service
}
Console struct {
ConsoleBackend struct {
Listener net.Listener
Service *console.Service
Endpoint *consoleweb.Server
}
ConsoleFrontend struct {
Listener net.Listener
Endpoint *consoleweb.Server
}
NodeStats struct {
Endpoint *nodestats.Endpoint
}
@ -298,6 +304,11 @@ func (system *Satellite) Run(ctx context.Context) (err error) {
group.Go(func() error {
return errs2.IgnoreCanceled(system.API.Run(ctx))
})
if system.UI != nil {
group.Go(func() error {
return errs2.IgnoreCanceled(system.UI.Run(ctx))
})
}
group.Go(func() error {
return errs2.IgnoreCanceled(system.Repairer.Run(ctx))
})
@ -519,6 +530,15 @@ func (planet *Planet) newSatellite(ctx context.Context, prefix string, index int
return nil, errs.Wrap(err)
}
// only run if front-end endpoints on console back-end server are disabled.
var ui *satellite.UI
if !config.Console.FrontendEnable {
ui, err = planet.newUI(ctx, index, identity, config, api.ExternalAddress, api.Console.Listener.Addr().String())
if err != nil {
return nil, errs.Wrap(err)
}
}
adminPeer, err := planet.newAdmin(ctx, index, identity, db, metabaseDB, config, versionInfo)
if err != nil {
return nil, errs.Wrap(err)
@ -548,19 +568,20 @@ func (planet *Planet) newSatellite(ctx context.Context, prefix string, index int
peer.Mail.EmailReminders.TestSetLinkAddress("http://" + api.Console.Listener.Addr().String() + "/")
}
return createNewSystem(prefix, log, config, peer, api, repairerPeer, auditorPeer, adminPeer, gcBFPeer, rangedLoopPeer), nil
return createNewSystem(prefix, log, config, peer, api, ui, repairerPeer, auditorPeer, adminPeer, gcBFPeer, rangedLoopPeer), nil
}
// createNewSystem makes a new Satellite System and exposes the same interface from
// before we split out the API. In the short term this will help keep all the tests passing
// without much modification needed. However long term, we probably want to rework this
// so it represents how the satellite will run when it is made up of many processes.
func createNewSystem(name string, log *zap.Logger, config satellite.Config, peer *satellite.Core, api *satellite.API, repairerPeer *satellite.Repairer, auditorPeer *satellite.Auditor, adminPeer *satellite.Admin, gcBFPeer *satellite.GarbageCollectionBF, rangedLoopPeer *satellite.RangedLoop) *Satellite {
func createNewSystem(name string, log *zap.Logger, config satellite.Config, peer *satellite.Core, api *satellite.API, ui *satellite.UI, repairerPeer *satellite.Repairer, auditorPeer *satellite.Auditor, adminPeer *satellite.Admin, gcBFPeer *satellite.GarbageCollectionBF, rangedLoopPeer *satellite.RangedLoop) *Satellite {
system := &Satellite{
Name: name,
Config: config,
Core: peer,
API: api,
UI: ui,
Repairer: repairerPeer,
Auditor: auditorPeer,
Admin: adminPeer,
@ -655,6 +676,15 @@ func (planet *Planet) newAPI(ctx context.Context, index int, identity *identity.
return satellite.NewAPI(log, identity, db, metabaseDB, revocationDB, liveAccounting, rollupsWriteCache, &config, versionInfo, nil)
}
func (planet *Planet) newUI(ctx context.Context, index int, identity *identity.FullIdentity, config satellite.Config, satelliteAddr, consoleAPIAddr string) (_ *satellite.UI, err error) {
defer mon.Task()(&ctx)(&err)
prefix := "satellite-ui" + strconv.Itoa(index)
log := planet.log.Named(prefix)
return satellite.NewUI(log, identity, &config, nil, satelliteAddr, consoleAPIAddr)
}
func (planet *Planet) newAdmin(ctx context.Context, index int, identity *identity.FullIdentity, db satellite.DB, metabaseDB *metabase.DB, config satellite.Config, versionInfo version.Info) (_ *satellite.Admin, err error) {
defer mon.Task()(&ctx)(&err)

View File

@ -53,7 +53,7 @@ type Auth struct {
}
// NewAuth is a constructor for api auth controller.
func NewAuth(log *zap.Logger, service *console.Service, accountFreezeService *console.AccountFreezeService, mailService *mailservice.Service, cookieAuth *consolewebauth.CookieAuth, analytics *analytics.Service, satelliteName string, externalAddress string, letUsKnowURL string, termsAndConditionsURL string, contactInfoURL string, generalRequestURL string) *Auth {
func NewAuth(log *zap.Logger, service *console.Service, accountFreezeService *console.AccountFreezeService, mailService *mailservice.Service, cookieAuth *consolewebauth.CookieAuth, analytics *analytics.Service, satelliteName, externalAddress, letUsKnowURL, termsAndConditionsURL, contactInfoURL, generalRequestURL string) *Auth {
return &Auth{
log: log,
ExternalAddress: externalAddress,

View File

@ -15,6 +15,7 @@ import (
"mime"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
@ -62,10 +63,14 @@ var (
// Config contains configuration for console web server.
type Config struct {
Address string `help:"server address of the graphql api gateway and frontend app" devDefault:"127.0.0.1:0" releaseDefault:":10100"`
StaticDir string `help:"path to static resources" default:""`
Watch bool `help:"whether to load templates on each request" default:"false" devDefault:"true"`
ExternalAddress string `help:"external endpoint of the satellite if hosted" default:""`
Address string `help:"server address of the graphql api gateway and frontend app" devDefault:"127.0.0.1:0" releaseDefault:":10100"`
FrontendAddress string `help:"server address of the front-end app" devDefault:"127.0.0.1:0" releaseDefault:":10200"`
ExternalAddress string `help:"external endpoint of the satellite if hosted" default:""`
FrontendEnable bool `help:"feature flag to toggle whether console back-end server should also serve front-end endpoints" default:"true"`
BackendReverseProxy string `help:"the target URL of console back-end reverse proxy for local development when running a UI server" default:""`
StaticDir string `help:"path to static resources" default:""`
Watch bool `help:"whether to load templates on each request" default:"false" devDefault:"true"`
AuthToken string `help:"auth token needed for access to registration token creation endpoint" default:"" testDefault:"very-secret-token"`
AuthTokenSecret string `help:"secret used to sign auth tokens" releaseDefault:"" devDefault:"my-suppa-secret-key"`
@ -220,7 +225,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
packagePlans: packagePlans,
}
logger.Debug("Starting Satellite UI.", zap.Stringer("Address", server.listener.Addr()))
logger.Debug("Starting Satellite Console server.", zap.Stringer("Address", server.listener.Addr()))
server.cookieAuth = consolewebauth.NewCookieAuth(consolewebauth.CookieSettings{
Name: "_tokenKey",
@ -353,30 +358,26 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
analyticsRouter.HandleFunc("/event", analyticsController.EventTriggered).Methods(http.MethodPost, http.MethodOptions)
analyticsRouter.HandleFunc("/page", analyticsController.PageEventTriggered).Methods(http.MethodPost, http.MethodOptions)
if server.config.StaticDir != "" {
oidc := oidc.NewEndpoint(
server.nodeURL, server.config.ExternalAddress,
logger, oidcService, service,
server.config.OauthCodeExpiry, server.config.OauthAccessTokenExpiry, server.config.OauthRefreshTokenExpiry,
)
oidc := oidc.NewEndpoint(
server.nodeURL, server.config.ExternalAddress,
logger, oidcService, service,
server.config.OauthCodeExpiry, server.config.OauthAccessTokenExpiry, server.config.OauthRefreshTokenExpiry,
)
router.HandleFunc("/.well-known/openid-configuration", oidc.WellKnownConfiguration)
router.Handle("/oauth/v2/authorize", server.withAuth(http.HandlerFunc(oidc.AuthorizeUser))).Methods(http.MethodPost)
router.Handle("/oauth/v2/tokens", server.ipRateLimiter.Limit(http.HandlerFunc(oidc.Tokens))).Methods(http.MethodPost)
router.Handle("/oauth/v2/userinfo", server.ipRateLimiter.Limit(http.HandlerFunc(oidc.UserInfo))).Methods(http.MethodGet)
router.Handle("/oauth/v2/clients/{id}", server.withAuth(http.HandlerFunc(oidc.GetClient))).Methods(http.MethodGet)
router.HandleFunc("/.well-known/openid-configuration", oidc.WellKnownConfiguration)
router.Handle("/oauth/v2/authorize", server.withAuth(http.HandlerFunc(oidc.AuthorizeUser))).Methods(http.MethodPost)
router.Handle("/oauth/v2/tokens", server.ipRateLimiter.Limit(http.HandlerFunc(oidc.Tokens))).Methods(http.MethodPost)
router.Handle("/oauth/v2/userinfo", server.ipRateLimiter.Limit(http.HandlerFunc(oidc.UserInfo))).Methods(http.MethodGet)
router.Handle("/oauth/v2/clients/{id}", server.withAuth(http.HandlerFunc(oidc.GetClient))).Methods(http.MethodGet)
router.HandleFunc("/invited", server.handleInvited)
router.HandleFunc("/activation", server.accountActivationHandler)
router.HandleFunc("/cancel-password-recovery", server.cancelPasswordRecoveryHandler)
if server.config.StaticDir != "" && server.config.FrontendEnable {
fs := http.FileServer(http.Dir(server.config.StaticDir))
router.PathPrefix("/static/").Handler(server.withCORS(server.brotliMiddleware(http.StripPrefix("/static", fs))))
router.HandleFunc("/invited", server.handleInvited)
// These paths previously required a trailing slash, so we support both forms for now
slashRouter := router.NewRoute().Subrouter()
slashRouter.StrictSlash(true)
slashRouter.HandleFunc("/activation", server.accountActivationHandler)
slashRouter.HandleFunc("/cancel-password-recovery", server.cancelPasswordRecoveryHandler)
if server.config.UseVuetifyProject {
router.PathPrefix("/vuetifypoc").Handler(server.withCORS(http.HandlerFunc(server.vuetifyAppHandler)))
}
@ -427,6 +428,100 @@ func (server *Server) Run(ctx context.Context) (err error) {
return group.Wait()
}
// NewFrontendServer creates new instance of console front-end server.
// NB: The return type is currently consoleweb.Server, but it does not contain all the dependencies.
// It should only be used with RunFrontEnd and Close. We plan on moving this to its own type, but
// right now since we have a feature flag to allow the backend server to continue serving the frontend, it
// makes it easier if they are the same type.
func NewFrontendServer(logger *zap.Logger, config Config, listener net.Listener, nodeURL storj.NodeURL, stripePublicKey string) (server *Server, err error) {
server = &Server{
log: logger,
config: config,
listener: listener,
nodeURL: nodeURL,
stripePublicKey: stripePublicKey,
}
logger.Debug("Starting Satellite UI server.", zap.Stringer("Address", server.listener.Addr()))
router := mux.NewRouter()
// N.B. This middleware has to be the first one because it has to be called
// the earliest in the HTTP chain.
router.Use(newTraceRequestMiddleware(logger, router))
// in local development, proxy certain requests to the console back-end server
if config.BackendReverseProxy != "" {
target, err := url.Parse(config.BackendReverseProxy)
if err != nil {
return nil, Error.Wrap(err)
}
proxy := httputil.NewSingleHostReverseProxy(target)
logger.Debug("Reverse proxy targeting", zap.String("address", config.BackendReverseProxy))
router.PathPrefix("/api").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
}))
router.PathPrefix("/oauth").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
}))
router.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
})
router.HandleFunc("/invited", func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
})
router.HandleFunc("/activation", func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
})
router.HandleFunc("/cancel-password-recovery", func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
})
router.HandleFunc("/registrationToken/", func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
})
router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
})
}
fs := http.FileServer(http.Dir(server.config.StaticDir))
router.HandleFunc("/robots.txt", server.seoHandler)
router.PathPrefix("/static/").Handler(server.brotliMiddleware(http.StripPrefix("/static", fs)))
router.HandleFunc("/config", server.frontendConfigHandler)
if server.config.UseVuetifyProject {
router.PathPrefix("/vuetifypoc").Handler(http.HandlerFunc(server.vuetifyAppHandler))
}
router.PathPrefix("/").Handler(http.HandlerFunc(server.appHandler))
server.server = http.Server{
Handler: server.withRequest(router),
MaxHeaderBytes: ContentLengthLimit.Int(),
}
return server, nil
}
// RunFrontend starts the server that runs the webapp.
func (server *Server) RunFrontend(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)
ctx, cancel := context.WithCancel(ctx)
var group errgroup.Group
group.Go(func() error {
<-ctx.Done()
return server.server.Shutdown(context.Background())
})
group.Go(func() error {
defer cancel()
err := server.server.Serve(server.listener)
if errs2.IsCanceled(err) || errors.Is(err, http.ErrServerClosed) {
err = nil
}
return err
})
return group.Wait()
}
// Close closes server and underlying listener.
func (server *Server) Close() error {
return server.server.Close()

View File

@ -5,6 +5,7 @@ package consoleweb_test
import (
"bytes"
"context"
"fmt"
"net/http"
"testing"
@ -219,3 +220,56 @@ func TestUserIDRateLimiter(t *testing.T) {
require.Equal(t, http.StatusTooManyRequests, applyCouponStatus(firstToken))
})
}
func TestConsoleBackendWithDisabledFrontEnd(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Console.FrontendEnable = false
config.Console.UseVuetifyProject = true
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
apiAddr := planet.Satellites[0].API.Console.Listener.Addr().String()
uiAddr := planet.Satellites[0].UI.Console.Listener.Addr().String()
testEndpoint(ctx, t, apiAddr, "/", http.StatusNotFound)
testEndpoint(ctx, t, apiAddr, "/vuetifypoc", http.StatusNotFound)
testEndpoint(ctx, t, apiAddr, "/static/", http.StatusNotFound)
testEndpoint(ctx, t, uiAddr, "/", http.StatusOK)
testEndpoint(ctx, t, uiAddr, "/vuetifypoc", http.StatusOK)
testEndpoint(ctx, t, uiAddr, "/static/", http.StatusOK)
})
}
func TestConsoleBackendWithEnabledFrontEnd(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Console.UseVuetifyProject = true
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
apiAddr := planet.Satellites[0].API.Console.Listener.Addr().String()
testEndpoint(ctx, t, apiAddr, "/", http.StatusOK)
testEndpoint(ctx, t, apiAddr, "/vuetifypoc", http.StatusOK)
testEndpoint(ctx, t, apiAddr, "/static/", http.StatusOK)
})
}
func testEndpoint(ctx context.Context, t *testing.T, addr, endpoint string, expectedStatus int) {
client := http.Client{}
url := "http://" + addr + endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
require.NoError(t, err)
result, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, expectedStatus, result.StatusCode)
require.NoError(t, result.Body.Close())
}

126
satellite/ui.go Normal file
View File

@ -0,0 +1,126 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package satellite
import (
"context"
"errors"
"net"
"runtime/pprof"
"github.com/spacemonkeygo/monkit/v3"
"github.com/zeebo/errs"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"storj.io/common/identity"
"storj.io/common/storj"
"storj.io/private/debug"
"storj.io/storj/private/lifecycle"
"storj.io/storj/satellite/console/consoleweb"
)
// UI is the satellite UI process.
//
// architecture: Peer
type UI struct {
Log *zap.Logger
Identity *identity.FullIdentity
DB DB
Servers *lifecycle.Group
Debug struct {
Listener net.Listener
Server *debug.Server
}
Console struct {
Listener net.Listener
Server *consoleweb.Server
}
}
// NewUI creates a new satellite UI process.
func NewUI(log *zap.Logger, full *identity.FullIdentity, config *Config, atomicLogLevel *zap.AtomicLevel, satelliteAddr, consoleBackendAddr string) (*UI, error) {
peer := &UI{
Log: log,
Identity: full,
Servers: lifecycle.NewGroup(log.Named("servers")),
}
{ // setup debug
var err error
if config.Debug.Address != "" {
peer.Debug.Listener, err = net.Listen("tcp", config.Debug.Address)
if err != nil {
withoutStack := errors.New(err.Error())
peer.Log.Debug("failed to start debug endpoints", zap.Error(withoutStack))
}
}
debugConfig := config.Debug
debugConfig.ControlTitle = "UI"
peer.Debug.Server = debug.NewServerWithAtomicLevel(log.Named("debug"), peer.Debug.Listener, monkit.Default, debugConfig, atomicLogLevel)
peer.Servers.Add(lifecycle.Item{
Name: "debug",
Run: peer.Debug.Server.Run,
Close: peer.Debug.Server.Close,
})
}
var err error
{ // setup console
consoleConfig := config.Console
peer.Console.Listener, err = net.Listen("tcp", consoleConfig.FrontendAddress)
if err != nil {
return nil, errs.Combine(err, peer.Close())
}
peer.Console.Server, err = consoleweb.NewFrontendServer(
peer.Log.Named("console:endpoint"),
consoleConfig,
peer.Console.Listener,
storj.NodeURL{ID: peer.ID(), Address: satelliteAddr},
config.Payments.StripeCoinPayments.StripePublicKey,
)
if err != nil {
return nil, errs.Combine(err, peer.Close())
}
peer.Servers.Add(lifecycle.Item{
Name: "console:endpoint",
Run: peer.Console.Server.RunFrontend,
Close: peer.Console.Server.Close,
})
}
return peer, nil
}
// Run runs satellite UI until it's either closed or it errors.
func (peer *UI) Run(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)
group, ctx := errgroup.WithContext(ctx)
pprof.Do(ctx, pprof.Labels("subsystem", "ui"), func(ctx context.Context) {
peer.Servers.Run(ctx, group)
pprof.Do(ctx, pprof.Labels("name", "subsystem-wait"), func(ctx context.Context) {
err = group.Wait()
})
})
return err
}
// Close closes all the resources.
func (peer *UI) Close() error {
return peer.Servers.Close()
}
// ID returns the peer ID.
func (peer *UI) ID() storj.NodeID { return peer.Identity.ID }

View File

@ -187,6 +187,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
# secret used to sign auth tokens
# console.auth-token-secret: ""
# the target URL of console back-end reverse proxy for local development when running a UI server
# console.backend-reverse-proxy: ""
# url link for for beta satellite feedback
# console.beta-satellite-feedback-url: ""
@ -262,6 +265,12 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
# allow domains to embed the satellite in a frame, space separated
# console.frame-ancestors: tardigrade.io storj.io
# server address of the front-end app
# console.frontend-address: :10200
# feature flag to toggle whether console back-end server should also serve front-end endpoints
# console.frontend-enable: true
# whether to show new gallery view
# console.gallery-view-enabled: false