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:
Ben McClelland
2026-03-10 08:20:19 -07:00
parent aebe09182b
commit a152f29113
4 changed files with 162 additions and 27 deletions
+107 -18
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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 {
+21
View File
@@ -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()
}