mirror of
https://github.com/versity/versitygw.git
synced 2026-08-17 12:46:23 +00:00
feat: option to host webui on s3 port
This adds the webui-s3-prefix option to specify a prefix and host the webui on the same port as the s3 service. Like the health endpoint, this will mask any bucket with the same name as the webui prefix. The benefit of hosting this on the same interface as the s3 service is no longer needing the CORS headers for the browser access if the webui and s3 access are on the same IP:PORT.
This commit is contained in:
+107
-18
@@ -101,6 +101,7 @@ var (
|
||||
webuiGateways []string
|
||||
webuiAdminGateways []string
|
||||
webuiPathPrefix string
|
||||
webuiS3Prefix string
|
||||
disableACLs bool
|
||||
)
|
||||
|
||||
@@ -240,6 +241,12 @@ func initFlags() []cli.Flag {
|
||||
EnvVars: []string{"VGW_WEBUI_PATH_PREFIX"},
|
||||
Destination: &webuiPathPrefix,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "webui-s3-prefix",
|
||||
Usage: "mount the WebUI on the S3 port at the given path prefix (e.g. '/ui'); must start with '/', must not be '/', and must not end with '/'",
|
||||
EnvVars: []string{"VGW_WEBUI_S3_PREFIX"},
|
||||
Destination: &webuiS3Prefix,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "access",
|
||||
Usage: "root user access key",
|
||||
@@ -756,16 +763,16 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
return fmt.Errorf("root user access and secret key must be provided")
|
||||
}
|
||||
|
||||
err := validateWebUIPathPrefix(webuiPathPrefix)
|
||||
err := validateWebUIPathPrefix("--webui-path-prefix", webuiPathPrefix)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if maxConnections < 1 {
|
||||
log.Fatal("max-connections must be positive")
|
||||
return fmt.Errorf("max-connections must be positive")
|
||||
}
|
||||
if maxRequests < 1 {
|
||||
log.Fatal("max-requests must be positive")
|
||||
return fmt.Errorf("max-requests must be positive")
|
||||
}
|
||||
if maxRequests > maxConnections {
|
||||
log.Printf("WARNING: max-requests (%d) exceeds max-connections (%d) which could allow for gateway to panic before throttling requests",
|
||||
@@ -774,7 +781,7 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
|
||||
// Ensure we have at least one port specified
|
||||
if len(ports) == 0 {
|
||||
log.Fatal("no ports specified")
|
||||
return fmt.Errorf("no ports specified")
|
||||
}
|
||||
|
||||
// WebUI runs in a browser and typically talks to the gateway/admin APIs cross-origin
|
||||
@@ -813,7 +820,13 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
}
|
||||
|
||||
// Validate port conflicts across s3 api, admin, and webui ports
|
||||
if err := validatePortConflicts(ports, admPorts, webuiPorts); err != nil {
|
||||
err = validatePortConflicts(ports, admPorts, webuiPorts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = validateWebUIPathPrefix("--webui-s3-prefix", webuiS3Prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -962,6 +975,57 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
return fmt.Errorf("init bucket event notifications: %w", err)
|
||||
}
|
||||
|
||||
if webuiS3Prefix != "" {
|
||||
s3SSLEnabled := certFile != ""
|
||||
s3AdmSSLEnabled := s3SSLEnabled
|
||||
if len(admPorts) > 0 {
|
||||
s3AdmSSLEnabled = admCertFile != ""
|
||||
}
|
||||
|
||||
var s3WebGateways []string
|
||||
if len(webuiGateways) > 0 {
|
||||
validGateways, err := validateGatewayURLs(webuiGateways, "webui gateway")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s3WebGateways = validGateways
|
||||
} else {
|
||||
for _, p := range ports {
|
||||
urls, err := buildServiceURLs(p, s3SSLEnabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webui-s3-prefix: build gateway URLs: %w", err)
|
||||
}
|
||||
s3WebGateways = append(s3WebGateways, urls...)
|
||||
}
|
||||
sortGatewayURLs(s3WebGateways)
|
||||
}
|
||||
|
||||
s3WebAdminGateways := s3WebGateways
|
||||
if len(webuiAdminGateways) > 0 {
|
||||
validAdminGateways, err := validateGatewayURLs(webuiAdminGateways, "webui admin gateway")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s3WebAdminGateways = validAdminGateways
|
||||
} else if len(admPorts) > 0 {
|
||||
s3WebAdminGateways = nil
|
||||
for _, admPort := range admPorts {
|
||||
urls, err := buildServiceURLs(admPort, s3AdmSSLEnabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webui-s3-prefix: build admin gateway URLs: %w", err)
|
||||
}
|
||||
s3WebAdminGateways = append(s3WebAdminGateways, urls...)
|
||||
}
|
||||
sortGatewayURLs(s3WebAdminGateways)
|
||||
}
|
||||
|
||||
opts = append(opts, s3api.WithWebUI(webuiS3Prefix, &webui.ServerConfig{
|
||||
Gateways: s3WebGateways,
|
||||
AdminGateways: s3WebAdminGateways,
|
||||
Region: region,
|
||||
}))
|
||||
}
|
||||
|
||||
srv, err := s3api.New(be, middlewares.RootUserConfig{
|
||||
Access: rootUserAccess,
|
||||
Secret: rootUserSecret,
|
||||
@@ -976,10 +1040,10 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
var opts []s3api.AdminOpt
|
||||
|
||||
if adminMaxConnections < 1 {
|
||||
log.Fatal("admin-max-connections must be positive")
|
||||
return fmt.Errorf("admin-max-connections must be positive")
|
||||
}
|
||||
if adminMaxRequests < 1 {
|
||||
log.Fatal("admin-max-requests must be positive")
|
||||
return fmt.Errorf("admin-max-requests must be positive")
|
||||
}
|
||||
if adminMaxRequests > adminMaxConnections {
|
||||
log.Printf("WARNING: admin-max-requests (%d) exceeds admin-max-connections (%d) which could allow for gateway to panic before throttling requests",
|
||||
@@ -1133,7 +1197,7 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
printBanner(ports, admPorts, certFile != "" || keyFile != "", admCertFile != "" || admKeyFile != "", webuiPorts, webuiSSLEnabled, webuiPathPrefix)
|
||||
printBanner(ports, admPorts, certFile != "" || keyFile != "", admCertFile != "" || admKeyFile != "", webuiPorts, webuiSSLEnabled, webuiPathPrefix, webuiS3Prefix)
|
||||
}
|
||||
|
||||
servers := 1
|
||||
@@ -1259,7 +1323,7 @@ Loop:
|
||||
return saveErr
|
||||
}
|
||||
|
||||
func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs []string, webuiSsl bool, webuiPathPrefix string) {
|
||||
func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs []string, webuiSsl bool, webuiPathPrefix string, webuiS3Prefix string) {
|
||||
if len(ports) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "No ports specified\n")
|
||||
return
|
||||
@@ -1480,6 +1544,25 @@ func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs
|
||||
}
|
||||
}
|
||||
|
||||
if webuiS3Prefix != "" {
|
||||
lines = append(lines,
|
||||
centerText(""),
|
||||
leftText("WebUI embedded on S3 service at:"),
|
||||
)
|
||||
for _, addrPort := range allInterfaces {
|
||||
ip, prt, err := net.SplitHostPort(addrPort)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hostPort := net.JoinHostPort(ip, prt)
|
||||
url := fmt.Sprintf("http://%s", hostPort)
|
||||
if ssl {
|
||||
url = fmt.Sprintf("https://%s", hostPort)
|
||||
}
|
||||
lines = append(lines, leftText(" "+url+webuiS3Prefix))
|
||||
}
|
||||
}
|
||||
|
||||
// Print the top border
|
||||
fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐")
|
||||
|
||||
@@ -1644,35 +1727,41 @@ func validateGatewayURLs(urls []string, urlType string) ([]string, error) {
|
||||
return validURLs, nil
|
||||
}
|
||||
|
||||
// validateWebUIPathPrefix validates --webui-path-prefix.
|
||||
// validateWebUIPathPrefix validates webui path prefix.
|
||||
// Accepted format is a single path segment like "/ui".
|
||||
func validateWebUIPathPrefix(prefix string) error {
|
||||
func validateWebUIPathPrefix(option, prefix string) error {
|
||||
if prefix == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(prefix) != prefix {
|
||||
return fmt.Errorf("invalid --webui-path-prefix %q: must not contain leading or trailing whitespace", prefix)
|
||||
return fmt.Errorf("invalid %v %q: must not contain leading or trailing whitespace",
|
||||
option, prefix)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(prefix, "/") {
|
||||
return fmt.Errorf("invalid --webui-path-prefix %q: must start with '/' (example: '/ui')", prefix)
|
||||
return fmt.Errorf("invalid %v %q: must start with '/' (example: '/ui')",
|
||||
option, prefix)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(prefix, "/") {
|
||||
return fmt.Errorf("invalid --webui-path-prefix %q: must not end with '/'", prefix)
|
||||
return fmt.Errorf("invalid %v %q: must not end with '/'",
|
||||
option, prefix)
|
||||
}
|
||||
|
||||
if strings.Count(prefix, "/") > 1 {
|
||||
return fmt.Errorf("invalid --webui-path-prefix %q: only a single path segment is allowed (example: '/ui')", prefix)
|
||||
return fmt.Errorf("invalid %v %q: only a single path segment is allowed (example: '/ui')",
|
||||
option, prefix)
|
||||
}
|
||||
|
||||
if strings.ContainsAny(prefix, "?#") {
|
||||
return fmt.Errorf("invalid --webui-path-prefix %q: query strings and fragments are not allowed", prefix)
|
||||
return fmt.Errorf("invalid %v %q: query strings and fragments are not allowed",
|
||||
option, prefix)
|
||||
}
|
||||
|
||||
if strings.Contains(prefix, "\\") {
|
||||
return fmt.Errorf("invalid --webui-path-prefix %q: backslashes are not allowed", prefix)
|
||||
return fmt.Errorf("invalid %v %q: backslashes are not allowed",
|
||||
option, prefix)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -243,6 +243,13 @@ ROOT_SECRET_ACCESS_KEY=
|
||||
# subpath instead of the URL root. Leave unset to serve from '/'.
|
||||
#VGW_WEBUI_PATH_PREFIX=
|
||||
|
||||
# The VGW_WEBUI_S3_PREFIX option sets the Web GUI URL path prefix on the S3
|
||||
# service endpoint (VGW_PORT), allowing the Web GUI to be hosted on the same
|
||||
# port as S3 (for example, '/ui'). Requests matching this prefix are routed
|
||||
# to the Web GUI, and buckets with the same name as the prefix are masked.
|
||||
# Leave unset to disable Web GUI hosting on the S3 service endpoint.
|
||||
#VGW_WEBUI_S3_PREFIX=
|
||||
|
||||
# The VGW_WEBUI_CERT and VGW_WEBUI_KEY options specify the TLS certificate and
|
||||
# private key for the Web GUI server. If these are not specified and TLS is
|
||||
# configured for the gateway (VGW_CERT and VGW_KEY), the Web GUI will use the
|
||||
|
||||
+27
-9
@@ -35,6 +35,7 @@ import (
|
||||
"github.com/versity/versitygw/s3err"
|
||||
"github.com/versity/versitygw/s3event"
|
||||
"github.com/versity/versitygw/s3log"
|
||||
"github.com/versity/versitygw/webui"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -42,15 +43,17 @@ const (
|
||||
)
|
||||
|
||||
type S3ApiServer struct {
|
||||
Router *S3ApiRouter
|
||||
app *fiber.App
|
||||
backend backend.Backend
|
||||
CertStorage *utils.CertStorage
|
||||
quiet bool
|
||||
keepAlive bool
|
||||
health string
|
||||
maxConnections int
|
||||
maxRequests int
|
||||
Router *S3ApiRouter
|
||||
app *fiber.App
|
||||
backend backend.Backend
|
||||
CertStorage *utils.CertStorage
|
||||
quiet bool
|
||||
keepAlive bool
|
||||
health string
|
||||
maxConnections int
|
||||
maxRequests int
|
||||
webuiMountPrefix string
|
||||
webuiSrvCfg *webui.ServerConfig
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -120,6 +123,11 @@ func New(
|
||||
})
|
||||
}
|
||||
|
||||
// Set up WebUI on the S3 port if configured
|
||||
if server.webuiSrvCfg != nil {
|
||||
webui.MountOn(app, server.webuiMountPrefix, server.webuiSrvCfg)
|
||||
}
|
||||
|
||||
// initialize total requests cap limiter middleware
|
||||
app.Use(middlewares.RateLimiter(server.maxRequests, mm, l))
|
||||
|
||||
@@ -185,6 +193,16 @@ func WithCORSAllowOrigin(origin string) Option {
|
||||
return func(s *S3ApiServer) { s.Router.corsAllowOrigin = origin }
|
||||
}
|
||||
|
||||
// WithWebUI mounts the WebUI on the S3 server's Fiber app at the given path prefix,
|
||||
// before S3 routes are registered. The prefix must start with "/" and must not be
|
||||
// empty or just "/".
|
||||
func WithWebUI(prefix string, cfg *webui.ServerConfig) Option {
|
||||
return func(s *S3ApiServer) {
|
||||
s.webuiMountPrefix = prefix
|
||||
s.webuiSrvCfg = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// WithConcurrencyLimiter sets the server's maximum connection limit
|
||||
// and the hard limit for in-flight requests.
|
||||
func WithConcurrencyLimiter(maxConnections, maxRequests int) Option {
|
||||
|
||||
@@ -116,6 +116,14 @@ func (s *Server) setupRoutes() {
|
||||
PathPrefix: "web",
|
||||
Browse: false,
|
||||
}))
|
||||
|
||||
// Catch-all: absorb any request the filesystem did not fully handle.
|
||||
// The filesystem middleware calls Next() for non-GET/HEAD methods and for
|
||||
// paths not found in the embedded FS, which would otherwise fall through
|
||||
// to the S3 router and be interpreted as bucket/object operations.
|
||||
s.app.Use(prefix+"/", func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
// handleIndexHTML serves index.html with server config injected as an inline script.
|
||||
@@ -193,3 +201,16 @@ func (s *Server) ServeMultiPort(ports []string) error {
|
||||
func (s *Server) Shutdown() error {
|
||||
return s.app.Shutdown()
|
||||
}
|
||||
|
||||
// MountOn registers the WebUI routes on an existing Fiber app at the given path prefix.
|
||||
// This allows hosting the WebUI on the same port as another service (e.g. the S3 API server).
|
||||
// The prefix must start with "/" and must not be empty or just "/".
|
||||
func MountOn(app *fiber.App, prefix string, cfg *ServerConfig) {
|
||||
s := &Server{
|
||||
app: app,
|
||||
config: cfg,
|
||||
pathPrefix: prefix,
|
||||
}
|
||||
fmt.Printf("initializing web dashboard\n")
|
||||
s.setupRoutes()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user