feat: add multi-address listener for s3/admin/webui

This allows specifying the following options more than once:
port, admin-port, webui

or using a comma-separated list for the env vars:
e.g., VGW_PORT=:7070,:8080,localhost:9090

This will also expand multiple interfaces from hostnames, for example
"localhost" in this case would resolve to both IPv4 and IPv6 interfaces:
localhost has address 127.0.0.1
localhost has IPv6 address ::1

This updates the banner to reflect all of the listening interfaces/ports,
and starts the service listener on all requested interfaces/ports.

Fixes #1761
This commit is contained in:
Ben McClelland
2026-02-17 14:16:43 -08:00
parent 919436d473
commit 599ab1b743
9 changed files with 1223 additions and 197 deletions
+113 -1
View File
@@ -31,9 +31,9 @@ func initEnv(dir string) {
rootUserAccess = "user"
rootUserSecret = "pass"
iamDir = dir
port = "127.0.0.1:7070"
maxConnections = 250000
maxRequests = 100000
ports = []string{"127.0.0.1:7070"}
// client
awsID = "user"
@@ -112,3 +112,115 @@ func TestIntegration(t *testing.T) {
cancel()
wg.Wait()
}
func TestValidatePortConflicts(t *testing.T) {
tests := []struct {
name string
ports []string
admPorts []string
webuiPorts []string
expectError bool
description string
}{
{
name: "bare port conflict with bare port",
ports: []string{":7071"},
admPorts: []string{},
webuiPorts: []string{":7071"},
expectError: true,
description: "should fail: bare :7071 conflicts with bare :7071",
},
{
name: "bare port conflict with IP:port",
ports: []string{":7071"},
admPorts: []string{},
webuiPorts: []string{"127.0.0.1:7071"},
expectError: true,
description: "should fail: bare :7071 conflicts with 127.0.0.1:7071",
},
{
name: "IP:port conflict with bare port",
ports: []string{"127.0.0.1:7071"},
admPorts: []string{},
webuiPorts: []string{":7071"},
expectError: true,
description: "should fail: 127.0.0.1:7071 conflicts with bare :7071",
},
{
name: "same IP:port allowed",
ports: []string{"127.0.0.1:7071"},
admPorts: []string{},
webuiPorts: []string{"127.0.0.1:7071"},
expectError: false,
description: "should pass: identical IP:port specs are allowed",
},
{
name: "different IP:port no conflict",
ports: []string{"127.0.0.1:7071"},
admPorts: []string{},
webuiPorts: []string{"127.0.0.1:7072"},
expectError: false,
description: "should pass: different ports don't conflict",
},
{
name: "different IP same port no conflict when both have IP",
ports: []string{"127.0.0.1:7071"},
admPorts: []string{},
webuiPorts: []string{"192.168.1.1:7071"},
expectError: false,
description: "should pass: different IPs with same port are okay",
},
{
name: "admin port conflict with s3 port",
ports: []string{":7070"},
admPorts: []string{"127.0.0.1:7070"},
webuiPorts: []string{},
expectError: true,
description: "should fail: admin port conflicts with s3 port",
},
{
name: "all three conflict",
ports: []string{":8080"},
admPorts: []string{"127.0.0.1:8080"},
webuiPorts: []string{"192.168.1.1:8080"},
expectError: true,
description: "should fail: bare port conflicts with both admin and webui",
},
{
name: "no conflicts",
ports: []string{":7070"},
admPorts: []string{":8080"},
webuiPorts: []string{":9090"},
expectError: false,
description: "should pass: all different ports",
},
{
name: "IPv6 bare port conflict with IPv4 specified",
ports: []string{":7071"},
admPorts: []string{},
webuiPorts: []string{"[::1]:7071"},
expectError: true,
description: "should fail: bare :7071 conflicts with [::1]:7071",
},
{
name: "multiple ports with one conflict",
ports: []string{":7070", ":8080"},
admPorts: []string{":9090"},
webuiPorts: []string{"127.0.0.1:8080"},
expectError: true,
description: "should fail: :8080 conflicts with 127.0.0.1:8080",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validatePortConflicts(tt.ports, tt.admPorts, tt.webuiPorts)
if tt.expectError && err == nil {
t.Errorf("%s: expected error but got none", tt.description)
}
if !tt.expectError && err != nil {
t.Errorf("%s: expected no error but got: %v", tt.description, err)
}
})
}
}
+330 -154
View File
@@ -39,7 +39,8 @@ import (
)
var (
port, admPort string
ports []string
admPorts []string
rootUserAccess string
rootUserSecret string
region string
@@ -93,7 +94,7 @@ var (
ipaUser, ipaPassword string
ipaInsecure bool
iamDebug bool
webuiAddr string
webuiPorts []string
webuiCertFile, webuiKeyFile string
webuiNoTLS bool
)
@@ -144,6 +145,13 @@ VersityGW is an open-source project licensed under the Apache 2.0 License. The
source code is hosted on GitHub at https://github.com/versity/versitygw, and
documentation can be found in the GitHub wiki.`,
Copyright: "Copyright (c) 2023-2024 Versity Software",
Before: func(ctx *cli.Context) error {
// Initialize global variables from context (including default values)
ports = ctx.StringSlice("port")
webuiPorts = ctx.StringSlice("webui")
admPorts = ctx.StringSlice("admin-port")
return nil
},
Action: func(ctx *cli.Context) error {
return ctx.App.Command("help").Run(ctx)
},
@@ -165,19 +173,17 @@ func initFlags() []cli.Flag {
return nil
},
},
&cli.StringFlag{
Name: "port",
Usage: "gateway listen address <ip>:<port> or :<port>",
EnvVars: []string{"VGW_PORT"},
Value: ":7070",
Destination: &port,
Aliases: []string{"p"},
&cli.StringSliceFlag{
Name: "port",
Usage: "gateway listen address <ip>:<port> or :<port> (can be specified multiple times for listening on multiple addresses)",
EnvVars: []string{"VGW_PORT"},
Value: cli.NewStringSlice(":7070"),
Aliases: []string{"p"},
},
&cli.StringFlag{
Name: "webui",
Usage: "enable WebUI server on the specified listen address (e.g. ':7071', '127.0.0.1:7071', 'localhost:7071'; disabled when omitted)",
EnvVars: []string{"VGW_WEBUI_PORT"},
Destination: &webuiAddr,
&cli.StringSliceFlag{
Name: "webui",
Usage: "enable WebUI server on the specified listen address (e.g. ':7071', '127.0.0.1:7071', 'localhost:7071'; can be specified multiple times for listening on multiple addresses; disabled when omitted)",
EnvVars: []string{"VGW_WEBUI_PORT"},
},
&cli.StringFlag{
Name: "webui-cert",
@@ -253,12 +259,11 @@ func initFlags() []cli.Flag {
EnvVars: []string{"VGW_KEY"},
Destination: &keyFile,
},
&cli.StringFlag{
Name: "admin-port",
Usage: "gateway admin server listen address <ip>:<port> or :<port>",
EnvVars: []string{"VGW_ADMIN_PORT"},
Destination: &admPort,
Aliases: []string{"ap"},
&cli.StringSliceFlag{
Name: "admin-port",
Usage: "gateway admin server listen address <ip>:<port> or :<port> (can be specified multiple times for listening on multiple addresses)",
EnvVars: []string{"VGW_ADMIN_PORT"},
Aliases: []string{"ap"},
},
&cli.IntFlag{
Name: "admin-max-connections",
@@ -718,15 +723,15 @@ func runGateway(ctx context.Context, be backend.Backend) error {
maxRequests, maxConnections)
}
webuiAddr = strings.TrimSpace(webuiAddr)
if webuiAddr != "" && isAllDigits(webuiAddr) {
webuiAddr = ":" + webuiAddr
// Ensure we have at least one port specified
if len(ports) == 0 {
log.Fatal("no ports specified")
}
// WebUI runs in a browser and typically talks to the gateway/admin APIs cross-origin
// (different port). If no bucket CORS configuration exists, those API responses need
// a default Access-Control-Allow-Origin to be usable from the WebUI.
if webuiAddr != "" && strings.TrimSpace(corsAllowOrigin) == "" {
if len(webuiPorts) > 0 && strings.TrimSpace(corsAllowOrigin) == "" {
// A single Access-Control-Allow-Origin value cannot cover multiple specific
// origins. Default to '*' for usability and print a warning so operators can
// lock it down explicitly.
@@ -739,14 +744,18 @@ func runGateway(ctx context.Context, be backend.Backend) error {
// Suggest a more secure explicit origin based on the actual WebUI listening interfaces.
// (Browsers require an exact origin match; this is typically one chosen hostname/IP.)
var suggestion string
ips, ipsErr := getMatchingIPs(webuiAddr)
_, webPrt, prtErr := net.SplitHostPort(webuiAddr)
if ipsErr == nil && prtErr == nil && len(ips) > 0 {
origins := make([]string, 0, len(ips))
for _, ip := range ips {
origins = append(origins, fmt.Sprintf("%s://%s:%s", webuiScheme, ip, webPrt))
var allOrigins []string
for _, addr := range webuiPorts {
ips, ipsErr := getMatchingIPs(addr)
_, webPrt, prtErr := net.SplitHostPort(addr)
if ipsErr == nil && prtErr == nil && len(ips) > 0 {
for _, ip := range ips {
allOrigins = append(allOrigins, fmt.Sprintf("%s://%s:%s", webuiScheme, ip, webPrt))
}
}
suggestion = fmt.Sprintf("consider setting it to one of: %s (or your public hostname)", strings.Join(origins, ", "))
}
if len(allOrigins) > 0 {
suggestion = fmt.Sprintf("consider setting it to one of: %s (or your public hostname)", strings.Join(allOrigins, ", "))
} else {
suggestion = fmt.Sprintf("consider setting it to %s://<host>:<port>", webuiScheme)
}
@@ -754,6 +763,11 @@ func runGateway(ctx context.Context, be backend.Backend) error {
fmt.Fprintf(os.Stderr, "WARNING: --webui is enabled but --cors-allow-origin is not set; defaulting to '*'; %s\n", suggestion)
}
// Validate port conflicts across s3 api, admin, and webui ports
if err := validatePortConflicts(ports, admPorts, webuiPorts); err != nil {
return err
}
utils.SetBucketNameValidationStrict(!disableStrictBucketNames)
if pprof != "" {
@@ -786,7 +800,7 @@ func runGateway(ctx context.Context, be backend.Backend) error {
}
opts = append(opts, s3api.WithTLS(cs))
}
if admPort == "" {
if len(admPorts) == 0 {
opts = append(opts, s3api.WithAdminServer())
}
if quiet {
@@ -899,14 +913,16 @@ func runGateway(ctx context.Context, be backend.Backend) error {
srv, err := s3api.New(be, middlewares.RootUserConfig{
Access: rootUserAccess,
Secret: rootUserSecret,
}, port, region, iam, loggers.S3Logger, loggers.AdminLogger, evSender, metricsManager, opts...)
}, region, iam, loggers.S3Logger, loggers.AdminLogger, evSender, metricsManager, opts...)
if err != nil {
return fmt.Errorf("init gateway: %v", err)
}
var admSrv *s3api.S3AdminServer
if admPort != "" {
if len(admPorts) > 0 {
var opts []s3api.AdminOpt
if adminMaxConnections < 1 {
log.Fatal("admin-max-connections must be positive")
}
@@ -918,9 +934,10 @@ func runGateway(ctx context.Context, be backend.Backend) error {
adminMaxRequests, adminMaxConnections)
}
opts := []s3api.AdminOpt{
opts = []s3api.AdminOpt{
s3api.WithAdminConcurrencyLimiter(adminMaxConnections, adminMaxRequests),
}
if corsAllowOrigin != "" {
opts = append(opts, s3api.WithAdminCORSAllowOrigin(corsAllowOrigin))
}
@@ -947,24 +964,27 @@ func runGateway(ctx context.Context, be backend.Backend) error {
opts = append(opts, s3api.WithAdminDebug())
}
admSrv = s3api.NewAdminServer(be, middlewares.RootUserConfig{Access: rootUserAccess, Secret: rootUserSecret}, admPort, region, iam, loggers.AdminLogger, srv.Router.Ctrl, opts...)
admSrv = s3api.NewAdminServer(be, middlewares.RootUserConfig{Access: rootUserAccess, Secret: rootUserSecret}, region, iam, loggers.AdminLogger, srv.Router.Ctrl, opts...)
}
var webSrv *webui.Server
webuiSSLEnabled := false
webTLSCert := ""
webTLSKey := ""
if webuiAddr != "" {
_, webPrt, err := net.SplitHostPort(webuiAddr)
if err != nil {
return fmt.Errorf("webui listen address must be in the form ':port' or 'host:port': %w", err)
}
webPortNum, err := strconv.Atoi(webPrt)
if err != nil {
return fmt.Errorf("webui port must be a number: %w", err)
}
if webPortNum < 0 || webPortNum > 65535 {
return fmt.Errorf("webui port must be between 0 and 65535")
if len(webuiPorts) > 0 {
// Validate all webui addresses
for _, addr := range webuiPorts {
_, webPrt, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("webui listen address must be in the form ':port' or 'host:port': %w", err)
}
webPortNum, err := strconv.Atoi(webPrt)
if err != nil {
return fmt.Errorf("webui port must be a number: %w", err)
}
if webPortNum < 0 || webPortNum > 65535 {
return fmt.Errorf("webui port must be between 0 and 65535")
}
}
var webOpts []webui.Option
@@ -998,20 +1018,28 @@ func runGateway(ctx context.Context, be backend.Backend) error {
sslEnabled := certFile != ""
admSSLEnabled := sslEnabled
if admPort != "" {
if len(admPorts) > 0 {
admSSLEnabled = admCertFile != ""
}
gateways, err := buildServiceURLs(port, sslEnabled)
if err != nil {
return fmt.Errorf("webui: build gateway URLs: %w", err)
var gateways []string
for _, p := range ports {
urls, err := buildServiceURLs(p, sslEnabled)
if err != nil {
return fmt.Errorf("webui: build gateway URLs: %w", err)
}
gateways = append(gateways, urls...)
}
adminGateways := gateways
if admPort != "" {
adminGateways, err = buildServiceURLs(admPort, admSSLEnabled)
if err != nil {
return fmt.Errorf("webui: build admin gateway URLs: %w", err)
if len(admPorts) > 0 {
adminGateways = nil
for _, admPort := range admPorts {
urls, err := buildServiceURLs(admPort, admSSLEnabled)
if err != nil {
return fmt.Errorf("webui: build admin gateway URLs: %w", err)
}
adminGateways = append(adminGateways, urls...)
}
}
@@ -1020,7 +1048,6 @@ func runGateway(ctx context.Context, be backend.Backend) error {
}
webSrv = webui.NewServer(&webui.ServerConfig{
ListenAddr: webuiAddr,
Gateways: gateways,
AdminGateways: adminGateways,
Region: region,
@@ -1028,23 +1055,24 @@ func runGateway(ctx context.Context, be backend.Backend) error {
}
if !quiet {
printBanner(port, admPort, certFile != "", admCertFile != "", webuiAddr, webuiSSLEnabled)
printBanner(ports, admPorts, certFile != "" || keyFile != "", admCertFile != "" || admKeyFile != "", webuiPorts, webuiSSLEnabled)
}
servers := 1
if admPort != "" {
if len(admPorts) > 0 {
servers++
}
if webSrv != nil {
if len(webuiPorts) > 0 {
servers++
}
c := make(chan error, servers)
go func() { c <- srv.Serve() }()
if admPort != "" {
go func() { c <- admSrv.Serve() }()
go func() { c <- srv.ServeMultiPort(ports) }()
if len(admPorts) > 0 {
go func() { c <- admSrv.ServeMultiPort(admPorts) }()
}
if webSrv != nil {
go func() { c <- webSrv.Serve() }()
if len(webuiPorts) > 0 {
go func() { c <- webSrv.ServeMultiPort(webuiPorts) }()
}
// for/select blocks until shutdown
@@ -1078,7 +1106,7 @@ Loop:
fmt.Printf("srv cert reloaded (cert: %s, key: %s)\n", certFile, keyFile)
}
}
if admPort != "" && admCertFile != "" && admKeyFile != "" {
if len(admPorts) > 0 && admCertFile != "" && admKeyFile != "" {
err = admSrv.CertStorage.SetCertificate(admCertFile, admKeyFile)
if err != nil {
debuglogger.InternalError(fmt.Errorf("admSrv cert reload failed: %w", err))
@@ -1086,7 +1114,7 @@ Loop:
fmt.Printf("admSrv cert reloaded (cert: %s, key: %s)\n", admCertFile, admKeyFile)
}
}
if webSrv != nil && webTLSCert != "" && webTLSKey != "" {
if len(webuiPorts) > 0 && webTLSCert != "" && webTLSKey != "" {
err := webSrv.CertStorage.SetCertificate(webTLSCert, webTLSKey)
if err != nil {
debuglogger.InternalError(fmt.Errorf("webSrv cert reload failed: %w", err))
@@ -1153,19 +1181,63 @@ Loop:
return saveErr
}
func printBanner(port, admPort string, ssl, admSsl bool, webuiAddr string, webuiSsl bool) {
interfaces, err := getMatchingIPs(port)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match local IP addresses: %v\n", err)
func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs []string, webuiSsl bool) {
if len(ports) == 0 {
fmt.Fprintf(os.Stderr, "No ports specified\n")
return
}
var admInterfaces []string
if admPort != "" {
admInterfaces, err = getMatchingIPs(admPort)
// Collect all interfaces for all ports
var allInterfaces []string
var allPorts []string
interfaceMap := make(map[string]bool) // deduplicate
for _, portSpec := range ports {
interfaces, err := getMatchingIPs(portSpec)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match admin port local IP addresses: %v\n", err)
return
fmt.Fprintf(os.Stderr, "Failed to match local IP addresses for %s: %v\n", portSpec, err)
continue
}
_, prt, err := net.SplitHostPort(portSpec)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse port %s: %v\n", portSpec, err)
continue
}
allPorts = append(allPorts, prt)
for _, ip := range interfaces {
key := net.JoinHostPort(ip, prt)
if !interfaceMap[key] {
interfaceMap[key] = true
allInterfaces = append(allInterfaces, key)
}
}
}
if len(allInterfaces) == 0 {
fmt.Fprintf(os.Stderr, "Failed to resolve any listening addresses\n")
return
}
// Collect all admin interfaces for all admin ports
var allAdmInterfaces []string
admInterfaceMap := make(map[string]bool)
for _, admPort := range admPorts {
interfaces, err := getMatchingIPs(admPort)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match admin port local IP addresses for %s: %v\n", admPort, err)
continue
}
_, prt, err := net.SplitHostPort(admPort)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse admin port %s: %v\n", admPort, err)
continue
}
for _, ip := range interfaces {
key := net.JoinHostPort(ip, prt)
if !admInterfaceMap[key] {
admInterfaceMap[key] = true
allAdmInterfaces = append(allAdmInterfaces, key)
}
}
}
@@ -1173,26 +1245,36 @@ func printBanner(port, admPort string, ssl, admSsl bool, webuiAddr string, webui
version := fmt.Sprintf("Version %v, Build %v", Version, Build)
urls := []string{}
hst, prt, err := net.SplitHostPort(port)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse port: %v\n", err)
return
}
for _, ip := range interfaces {
url := fmt.Sprintf("http://%s:%s", ip, prt)
// Build URLs for all listening addresses
for _, addrPort := range allInterfaces {
ip, prt, err := net.SplitHostPort(addrPort)
if err != nil {
// Shouldn't happen as we constructed these properly, but handle it
continue
}
// Rebuild the host:port using JoinHostPort to ensure IPv6 addresses have brackets
hostPort := net.JoinHostPort(ip, prt)
url := fmt.Sprintf("http://%s", hostPort)
if ssl {
url = fmt.Sprintf("https://%s:%s", ip, prt)
url = fmt.Sprintf("https://%s", hostPort)
}
urls = append(urls, url)
}
if hst == "" {
hst = "0.0.0.0"
// Determine bound host description
var boundHost string
if len(ports) == 1 {
hst, prt, _ := net.SplitHostPort(ports[0])
if hst == "" {
hst = "0.0.0.0"
}
boundHost = fmt.Sprintf("(bound on host %s and port %s)", hst, prt)
} else {
// Multiple ports
portList := strings.Join(allPorts, ", ")
boundHost = fmt.Sprintf("(bound on ports: %s)", portList)
}
boundHost := fmt.Sprintf("(bound on host %s and port %s)", hst, prt)
lines := []string{
centerText(title),
centerText(version),
@@ -1200,7 +1282,7 @@ func printBanner(port, admPort string, ssl, admSsl bool, webuiAddr string, webui
centerText(""),
}
if len(admInterfaces) > 0 {
if len(allAdmInterfaces) > 0 {
lines = append(lines,
leftText("S3 service listening on:"),
)
@@ -1214,48 +1296,71 @@ func printBanner(port, admPort string, ssl, admSsl bool, webuiAddr string, webui
lines = append(lines, leftText(" "+url))
}
if len(admInterfaces) > 0 {
if len(allAdmInterfaces) > 0 {
lines = append(lines,
centerText(""),
leftText("Admin service listening on:"),
)
_, prt, err := net.SplitHostPort(admPort)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse port: %v\n", err)
return
}
for _, ip := range admInterfaces {
url := fmt.Sprintf("http://%s:%s", ip, prt)
for _, addrPort := range allAdmInterfaces {
ip, prt, err := net.SplitHostPort(addrPort)
if err != nil {
continue
}
hostPort := net.JoinHostPort(ip, prt)
url := fmt.Sprintf("http://%s", hostPort)
if admSsl {
url = fmt.Sprintf("https://%s:%s", ip, prt)
url = fmt.Sprintf("https://%s", hostPort)
}
lines = append(lines, leftText(" "+url))
}
}
if strings.TrimSpace(webuiAddr) != "" {
webInterfaces, err := getMatchingIPs(webuiAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match webui port local IP addresses: %v\n", err)
return
// Collect all webui interfaces for all webui addresses
if len(webuiAddrs) > 0 {
var allWebInterfaces []string
webInterfaceMap := make(map[string]bool)
for _, webuiAddr := range webuiAddrs {
if strings.TrimSpace(webuiAddr) == "" {
continue
}
webInterfaces, err := getMatchingIPs(webuiAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match webui port local IP addresses for %s: %v\n", webuiAddr, err)
continue
}
_, webPrt, err := net.SplitHostPort(webuiAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse webui port %s: %v\n", webuiAddr, err)
continue
}
for _, ip := range webInterfaces {
key := net.JoinHostPort(ip, webPrt)
if !webInterfaceMap[key] {
webInterfaceMap[key] = true
allWebInterfaces = append(allWebInterfaces, key)
}
}
}
_, webPrt, err := net.SplitHostPort(webuiAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse webui port: %v\n", err)
return
}
lines = append(lines,
centerText(""),
leftText("WebUI listening on:"),
)
for _, ip := range webInterfaces {
url := fmt.Sprintf("http://%s:%s", ip, webPrt)
if webuiSsl {
url = fmt.Sprintf("https://%s:%s", ip, webPrt)
if len(allWebInterfaces) > 0 {
lines = append(lines,
centerText(""),
leftText("WebUI listening on:"),
)
for _, addrPort := range allWebInterfaces {
ip, prt, err := net.SplitHostPort(addrPort)
if err != nil {
continue
}
hostPort := net.JoinHostPort(ip, prt)
url := fmt.Sprintf("http://%s", hostPort)
if webuiSsl {
url = fmt.Sprintf("https://%s", hostPort)
}
lines = append(lines, leftText(" "+url))
}
lines = append(lines, leftText(" "+url))
}
}
@@ -1271,63 +1376,62 @@ func printBanner(port, admPort string, ssl, admSsl bool, webuiAddr string, webui
fmt.Println("└" + strings.Repeat("─", columnWidth-2) + "┘")
}
// getMatchingIPs returns all IP addresses for local system interfaces that
// match the input address specification.
// getMatchingIPs returns all IP addresses that the server will listen on
// for the given address specification. For hostnames, it resolves to all
// IP addresses (e.g., localhost -> 127.0.0.1 and ::1).
func getMatchingIPs(spec string) ([]string, error) {
// Split the input spec into IP and port
host, _, err := net.SplitHostPort(spec)
ips, err := utils.ResolveHostnameIPs(spec)
if err != nil {
return nil, fmt.Errorf("parse address/port: %v", err)
return nil, fmt.Errorf("resolve hostname: %v", err)
}
// Handle cases where IP is omitted (e.g., ":1234")
if host == "" {
host = "0.0.0.0"
// If empty host (e.g., ":8080"), enumerate all local interfaces
if len(ips) == 1 && ips[0] == "" {
return getAllLocalIPs()
}
ipaddr, err := net.ResolveIPAddr("ip", host)
if err != nil {
return nil, err
// Filter out link-local addresses
var result []string
for _, ip := range ips {
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
continue
}
if parsedIP.IsLinkLocalUnicast() || parsedIP.IsLinkLocalMulticast() || parsedIP.IsInterfaceLocalMulticast() {
continue
}
result = append(result, ip)
}
parsedInputIP := ipaddr.IP
return result, nil
}
// getAllLocalIPs returns all non-link-local IP addresses from local interfaces
func getAllLocalIPs() ([]string, error) {
var result []string
// Get all network interfaces
interfaces, err := net.Interfaces()
if err != nil {
return nil, err
}
for _, iface := range interfaces {
// Get all addresses associated with the interface
addrs, err := iface.Addrs()
if err != nil {
return nil, err
continue
}
for _, addr := range addrs {
// Parse the address to get the IP part
ipAddr, _, err := net.ParseCIDR(addr.String())
if err != nil {
return nil, err
}
if ipAddr.IsLinkLocalUnicast() {
continue
}
if ipAddr.IsInterfaceLocalMulticast() {
continue
}
if ipAddr.IsLinkLocalMulticast() {
continue
}
// Check if the IP matches the input specification
if parsedInputIP.Equal(net.IPv4(0, 0, 0, 0)) || parsedInputIP.Equal(ipAddr) {
result = append(result, ipAddr.String())
if ipAddr.IsLinkLocalUnicast() || ipAddr.IsInterfaceLocalMulticast() || ipAddr.IsLinkLocalMulticast() {
continue
}
result = append(result, ipAddr.String())
}
}
@@ -1358,16 +1462,88 @@ func buildServiceURLs(spec string, ssl bool) ([]string, error) {
return urls, nil
}
func isAllDigits(s string) bool {
if s == "" {
return false
// validatePortConflicts checks for port conflicts across s3 api, admin, and webui ports.
// A bare port spec (e.g., ":7071") binds to all interfaces and will conflict with any other
// binding on the same port, whether it's ":7071" or "ip:7071".
// However, two identical "ip:port" specs are allowed (will be caught by later errors).
// This is needed because net.Listen() does not return the address already in use
// error for the bare port spec arguments.
func validatePortConflicts(ports, admPorts, webuiPorts []string) error {
type portSpec struct {
spec string
port string
isBare bool
portType string // "s3", "admin", or "webui"
}
for _, r := range s {
if r < '0' || r > '9' {
return false
var allSpecs []portSpec
// Collect all port specs
for _, p := range ports {
_, port, err := net.SplitHostPort(p)
if err != nil {
continue // will be caught by later validation
}
allSpecs = append(allSpecs, portSpec{
spec: p,
port: port,
isBare: strings.HasPrefix(p, ":"),
portType: "s3",
})
}
for _, p := range admPorts {
_, port, err := net.SplitHostPort(p)
if err != nil {
continue // will be caught by later validation
}
allSpecs = append(allSpecs, portSpec{
spec: p,
port: port,
isBare: strings.HasPrefix(p, ":"),
portType: "admin",
})
}
for _, p := range webuiPorts {
_, port, err := net.SplitHostPort(p)
if err != nil {
continue // will be caught by later validation
}
allSpecs = append(allSpecs, portSpec{
spec: p,
port: port,
isBare: strings.HasPrefix(p, ":"),
portType: "webui",
})
}
// Check for conflicts
for i, spec1 := range allSpecs {
for j, spec2 := range allSpecs {
if i >= j {
continue // skip comparing with self and already compared pairs
}
// If ports don't match, no conflict
if spec1.port != spec2.port {
continue
}
// If both are identical IP:port specs, allow (will be caught later)
if !spec1.isBare && !spec2.isBare && spec1.spec == spec2.spec {
continue
}
// If either is a bare port spec, it's a conflict with any other spec on the same port
if spec1.isBare || spec2.isBare {
return fmt.Errorf("port conflict: --%s %s conflicts with --%s %s (bare port specs bind to all interfaces)",
spec1.portType, spec1.spec, spec2.portType, spec2.spec)
}
}
}
return true
return nil
}
const columnWidth = 70
+12 -4
View File
@@ -60,9 +60,15 @@ ROOT_SECRET_ACCESS_KEY=
# This option can use either the form <ip>:<port> which will listen only
# on the network interface that matches the IP on the specified port, or
# :<port> which will listen on all network interfaces on the specified port.
# When a hostname is specified that matches multiple interfaces, such as:
# localhost has address 127.0.0.1
# localhost has IPv6 address ::1
# all interfaces matching the hostname will be used.
# The <ip> spec can either be IP dotted notation or a resolvable hostname.
# The <port> spec can either be a numeric port or the service name typically
# in /etc/services.
# To specify multiple ports, use a comma-separated list
# (e.g., VGW_PORT=:7070,:8080,localhost:9090).
#VGW_PORT=:7070
# The VGW_REGION option will specify the region that the S3 server will
@@ -81,9 +87,10 @@ ROOT_SECRET_ACCESS_KEY=
# control of firewall restrictions to the admin endpoint. The certs for this
# can be different certs than specified for the S3 service. The default when
# these are not specified is to have the admin server listen on the same
# endpoint as the S3 service.
# When VGW_ADMIN_CERT and VGW_ADMIN_CERT_KEY are specified, the admin
# server will use SSL.
# endpoint as the S3 service. This can specify multiple ports with comma
# separated list and will resolve hostnames to multiple addresses the same
# as VGW_PORT. When VGW_ADMIN_CERT and VGW_ADMIN_CERT_KEY are specified,
# the admin server will use SSL.
#VGW_ADMIN_PORT=
#VGW_ADMIN_CERT=
#VGW_ADMIN_CERT_KEY=
@@ -210,7 +217,8 @@ ROOT_SECRET_ACCESS_KEY=
# users, buckets and objects. The format can be either ':port' to listen on all
# interfaces (e.g., ':7071') or 'host:port' to listen on a specific interface
# (e.g., '127.0.0.1:7071' or 'localhost:7071'). When omitted, the Web GUI is
# disabled.
# disabled. This can specify multiple ports with comma separated list and will
# resolve hostnames to multiple addresses the same as VGW_PORT.
#VGW_WEBUI_PORT=
# The VGW_WEBUI_CERT and VGW_WEBUI_KEY options specify the TLS certificate and
+37 -10
View File
@@ -15,6 +15,9 @@
package s3api
import (
"fmt"
"net"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
@@ -31,7 +34,6 @@ type S3AdminServer struct {
app *fiber.App
backend backend.Backend
router *S3AdminRouter
port string
CertStorage *utils.CertStorage
quiet bool
debug bool
@@ -40,13 +42,12 @@ type S3AdminServer struct {
maxRequests int
}
func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, port, region string, iam auth.IAMService, l s3log.AuditLogger, ctrl controllers.S3ApiController, opts ...AdminOpt) *S3AdminServer {
func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, region string, iam auth.IAMService, l s3log.AuditLogger, ctrl controllers.S3ApiController, opts ...AdminOpt) *S3AdminServer {
server := &S3AdminServer{
backend: be,
router: &S3AdminRouter{
s3api: ctrl,
},
port: port,
}
for _, opt := range opts {
@@ -123,16 +124,42 @@ func WithAdminConcurrencyLimiter(maxConnections, maxRequests int) AdminOpt {
}
}
func (sa *S3AdminServer) Serve() (err error) {
if sa.CertStorage != nil {
ln, err := utils.NewTLSListener(sa.app.Config().Network, sa.port, sa.CertStorage.GetCertificate)
if err != nil {
return err
// ServeMultiPort creates listeners for multiple port specifications and serves
// on all of them simultaneously. This supports listening on multiple ports and/or
// addresses (e.g., [":8080", "localhost:8081"]).
func (sa *S3AdminServer) ServeMultiPort(ports []string) error {
if len(ports) == 0 {
return fmt.Errorf("no ports specified")
}
// Multiple ports - create listeners for each
var listeners []net.Listener
for _, portSpec := range ports {
var ln net.Listener
var err error
if sa.CertStorage != nil {
ln, err = utils.NewMultiAddrTLSListener(sa.app.Config().Network, portSpec, sa.CertStorage.GetCertificate)
} else {
ln, err = utils.NewMultiAddrListener(sa.app.Config().Network, portSpec)
}
return sa.app.Listener(ln)
if err != nil {
return fmt.Errorf("failed to bind admin listener %s: %w", portSpec, err)
}
listeners = append(listeners, ln)
}
return sa.app.Listen(sa.port)
if len(listeners) == 0 {
return fmt.Errorf("failed to create any admin listeners")
}
// Combine all listeners
finalListener := utils.NewMultiListener(listeners...)
return sa.app.Listener(finalListener)
}
// ShutDown gracefully shuts down the server with a context timeout
+34 -9
View File
@@ -16,6 +16,8 @@ package s3api
import (
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
@@ -43,7 +45,6 @@ type S3ApiServer struct {
Router *S3ApiRouter
app *fiber.App
backend backend.Backend
port string
CertStorage *utils.CertStorage
quiet bool
readonly bool
@@ -58,7 +59,7 @@ type S3ApiServer struct {
func New(
be backend.Backend,
root middlewares.RootUserConfig,
port, region string,
region string,
iam auth.IAMService,
l s3log.AuditLogger,
adminLogger s3log.AuditLogger,
@@ -69,7 +70,6 @@ func New(
server := &S3ApiServer{
backend: be,
Router: new(S3ApiRouter),
port: port,
}
for _, opt := range opts {
@@ -186,16 +186,41 @@ func WithConcurrencyLimiter(maxConnections, maxRequests int) Option {
}
}
func (sa *S3ApiServer) Serve() (err error) {
if sa.CertStorage != nil {
ln, err := utils.NewTLSListener(sa.app.Config().Network, sa.port, sa.CertStorage.GetCertificate)
// ServeMultiPort creates listeners for multiple port specifications and serves
// on all of them simultaneously. This supports listening on multiple ports and/or
// addresses (e.g., [":7070", "localhost:8080", "0.0.0.0:9090"]).
func (sa *S3ApiServer) ServeMultiPort(ports []string) error {
if len(ports) == 0 {
return fmt.Errorf("no ports specified")
}
// Multiple ports - create listeners for each
var listeners []net.Listener
for _, portSpec := range ports {
var ln net.Listener
var err error
if sa.CertStorage != nil {
ln, err = utils.NewMultiAddrTLSListener(sa.app.Config().Network, portSpec, sa.CertStorage.GetCertificate)
} else {
ln, err = utils.NewMultiAddrListener(sa.app.Config().Network, portSpec)
}
if err != nil {
return err
return fmt.Errorf("failed to bind s3 listener %s: %w", portSpec, err)
}
return sa.app.Listener(ln)
listeners = append(listeners, ln)
}
return sa.app.Listen(sa.port)
if len(listeners) == 0 {
return fmt.Errorf("failed to create any s3 listeners")
}
// Combine all listeners
finalListener := utils.NewMultiListener(listeners...)
return sa.app.Listener(finalListener)
}
// ShutDown gracefully shuts down the server with a context timeout
+4 -3
View File
@@ -27,6 +27,7 @@ func TestS3ApiServer_Serve(t *testing.T) {
name string
sa *S3ApiServer
wantErr bool
port string
}{
{
name: "Serve-invalid-address",
@@ -34,9 +35,9 @@ func TestS3ApiServer_Serve(t *testing.T) {
sa: &S3ApiServer{
app: fiber.New(),
backend: backend.BackendUnsupported{},
port: "Invalid address",
Router: &S3ApiRouter{},
},
port: "Invalid address",
},
{
name: "Serve-invalid-address-with-certificate",
@@ -44,15 +45,15 @@ func TestS3ApiServer_Serve(t *testing.T) {
sa: &S3ApiServer{
app: fiber.New(),
backend: backend.BackendUnsupported{},
port: "Invalid address",
Router: &S3ApiRouter{},
CertStorage: &utils.CertStorage{},
},
port: "Invalid address",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.sa.Serve(); (err != nil) != tt.wantErr {
if err := tt.sa.ServeMultiPort([]string{tt.port}); (err != nil) != tt.wantErr {
t.Errorf("S3ApiServer.Serve() error = %v, wantErr %v", err, tt.wantErr)
}
})
+268
View File
@@ -0,0 +1,268 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package utils
import (
"crypto/tls"
"errors"
"fmt"
"net"
"sync"
)
// MultiListener implements net.Listener and accepts connections from multiple
// underlying listeners. This is useful for listening on multiple IP addresses
// that a hostname resolves to (e.g., both IPv4 and IPv6 for "localhost").
type MultiListener struct {
listeners []net.Listener
acceptCh chan acceptResult
closeCh chan struct{}
closeOnce sync.Once
wg sync.WaitGroup
}
type acceptResult struct {
conn net.Conn
err error
}
// NewMultiListener creates a new MultiListener that accepts connections from
// all provided listeners.
func NewMultiListener(listeners ...net.Listener) *MultiListener {
if len(listeners) == 0 {
return nil
}
ml := &MultiListener{
listeners: listeners,
acceptCh: make(chan acceptResult, 2*len(listeners)),
closeCh: make(chan struct{}),
}
// Start accepting from each listener in its own goroutine
for _, ln := range listeners {
ml.wg.Add(1)
go ml.acceptLoop(ln)
}
return ml
}
// acceptLoop continuously accepts connections from a single listener
// and forwards them to the accept channel
func (ml *MultiListener) acceptLoop(ln net.Listener) {
defer ml.wg.Done()
for {
conn, err := ln.Accept()
select {
case <-ml.closeCh:
// MultiListener is closing
if conn != nil {
conn.Close()
}
return
case ml.acceptCh <- acceptResult{conn: conn, err: err}:
// Connection or error sent successfully
if err != nil {
return
}
}
}
}
// Accept waits for and returns the next connection from any of the listeners
func (ml *MultiListener) Accept() (net.Conn, error) {
select {
case <-ml.closeCh:
return nil, errors.New("listener closed")
case result, ok := <-ml.acceptCh:
if !ok {
// Channel closed
return nil, errors.New("listener closed")
}
return result.conn, result.err
}
}
// Close closes all underlying listeners
func (ml *MultiListener) Close() error {
var errs []error
ml.closeOnce.Do(func() {
close(ml.closeCh)
// Close all listeners
for _, ln := range ml.listeners {
if err := ln.Close(); err != nil {
errs = append(errs, err)
}
}
// Wait for all accept loops to finish
ml.wg.Wait()
// Drain any remaining accepts
close(ml.acceptCh)
for range ml.acceptCh {
}
})
if len(errs) > 0 {
return fmt.Errorf("errors closing listeners: %v", errs)
}
return nil
}
// Addr returns the address of the first listener
func (ml *MultiListener) Addr() net.Addr {
if len(ml.listeners) > 0 {
return ml.listeners[0].Addr()
}
return nil
}
// ResolveHostnameIPs resolves a hostname to all its IP addresses (IPv4 and IPv6).
// If the input is already an IP address or empty, it returns it as-is.
// This is useful for determining all addresses a server will listen on.
func ResolveHostnameIPs(address string) ([]string, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid address %q: %w", address, err)
}
// Handle empty host (e.g., ":8080" means all interfaces)
if host == "" {
return []string{""}, nil
}
// If already an IP address, return as is
if net.ParseIP(host) != nil {
return []string{host}, nil
}
// Resolve hostname to all IP addresses
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("no addresses found for hostname %q", host)
}
// Convert IPs to strings
result := make([]string, 0, len(ips))
for _, ip := range ips {
result = append(result, ip.String())
}
return result, nil
}
// resolveHostnameAddrs resolves a hostname to all its IP addresses (IPv4 and IPv6)
// and returns them as a list of addresses with the port attached.
func resolveHostnameAddrs(address string) ([]string, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid address %q: %w", address, err)
}
// If host is empty or already an IP address, return as is
if host == "" || net.ParseIP(host) != nil {
return []string{address}, nil
}
// Resolve hostname to all IP addresses
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("no addresses found for hostname %q", host)
}
// Build list of addresses with port
addrs := make([]string, 0, len(ips))
for _, ip := range ips {
addr := net.JoinHostPort(ip.String(), port)
addrs = append(addrs, addr)
}
return addrs, nil
}
// NewMultiAddrListener creates listeners for all IP addresses that the hostname
// in the address resolves to. If the address is already an IP, it creates a
// single listener. Returns a MultiListener if multiple addresses are resolved,
// or a single listener if only one address is found.
func NewMultiAddrListener(network, address string) (net.Listener, error) {
addrs, err := resolveHostnameAddrs(address)
if err != nil {
return nil, err
}
// Create listeners for all resolved addresses
listeners := make([]net.Listener, 0, len(addrs))
for _, addr := range addrs {
ln, err := net.Listen(network, addr)
if err != nil {
// Close any listeners we've already created
for _, l := range listeners {
l.Close()
}
return nil, fmt.Errorf("failed to bind listener %s: %w", addr, err)
}
listeners = append(listeners, ln)
}
// Return MultiListener for multiple addresses
return NewMultiListener(listeners...), nil
}
// NewMultiAddrTLSListener creates TLS listeners for all IP addresses that the
// hostname in the address resolves to. Similar to NewMultiAddrListener but with TLS.
func NewMultiAddrTLSListener(network, address string, getCertificateFunc func(*tls.ClientHelloInfo) (*tls.Certificate, error)) (net.Listener, error) {
config := &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: getCertificateFunc,
}
addrs, err := resolveHostnameAddrs(address)
if err != nil {
return nil, err
}
// Create TLS listeners for all resolved addresses
listeners := make([]net.Listener, 0, len(addrs))
for _, addr := range addrs {
ln, err := net.Listen(network, addr)
if err != nil {
// Close any listeners we've already created
for _, l := range listeners {
l.Close()
}
return nil, fmt.Errorf("failed to bind TLS listener %s: %w", addr, err)
}
listeners = append(listeners, tls.NewListener(ln, config))
}
// Return MultiListener for multiple addresses
return NewMultiListener(listeners...), nil
}
+393
View File
@@ -0,0 +1,393 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package utils
import (
"crypto/tls"
"io"
"net"
"testing"
"time"
)
func TestMultiListener(t *testing.T) {
// Create multiple underlying listeners
ln1, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener 1: %v", err)
}
defer ln1.Close()
ln2, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener 2: %v", err)
}
defer ln2.Close()
// Create MultiListener
ml := NewMultiListener(ln1, ln2)
if ml == nil {
t.Fatal("NewMultiListener returned nil")
}
defer ml.Close()
// Test connections to both listeners
addr1 := ln1.Addr().String()
addr2 := ln2.Addr().String()
// Connect to first listener
go func() {
conn, err := net.Dial("tcp", addr1)
if err != nil {
t.Errorf("Failed to dial first address: %v", err)
return
}
defer conn.Close()
conn.Write([]byte("hello from ln1"))
}()
// Accept from MultiListener
conn1, err := ml.Accept()
if err != nil {
t.Fatalf("Failed to accept from MultiListener: %v", err)
}
defer conn1.Close()
buf := make([]byte, 100)
n, _ := conn1.Read(buf)
if string(buf[:n]) != "hello from ln1" {
t.Errorf("Unexpected data from first connection: %s", string(buf[:n]))
}
// Connect to second listener
go func() {
conn, err := net.Dial("tcp", addr2)
if err != nil {
t.Errorf("Failed to dial second address: %v", err)
return
}
defer conn.Close()
conn.Write([]byte("hello from ln2"))
}()
// Accept from MultiListener
conn2, err := ml.Accept()
if err != nil {
t.Fatalf("Failed to accept second connection: %v", err)
}
defer conn2.Close()
n, _ = conn2.Read(buf)
if string(buf[:n]) != "hello from ln2" {
t.Errorf("Unexpected data from second connection: %s", string(buf[:n]))
}
}
func TestMultiListenerClose(t *testing.T) {
ln1, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
ml := NewMultiListener(ln1)
if ml == nil {
t.Fatal("NewMultiListener returned nil")
}
// Start accepting in a goroutine
acceptErrors := make(chan error, 1)
go func() {
_, err := ml.Accept()
acceptErrors <- err
}()
// Give the accept goroutine time to start
time.Sleep(100 * time.Millisecond)
// Close the MultiListener
if err := ml.Close(); err != nil {
t.Errorf("Close() returned error: %v", err)
}
// The accept should now return an error
select {
case err := <-acceptErrors:
if err == nil {
t.Error("Accept() should fail after Close()")
}
case <-time.After(2 * time.Second):
t.Error("Accept() did not return after Close()")
}
// Try to accept after close - should fail immediately
_, err = ml.Accept()
if err == nil {
t.Error("Accept() should fail after Close() on subsequent calls")
}
}
func TestResolveHostnameAddrs(t *testing.T) {
tests := []struct {
name string
address string
wantErr bool
checkResult func([]string) bool
}{
{
name: "IPv4 address",
address: "127.0.0.1:8080",
wantErr: false,
checkResult: func(addrs []string) bool {
return len(addrs) == 1 && addrs[0] == "127.0.0.1:8080"
},
},
{
name: "IPv6 address",
address: "[::1]:8080",
wantErr: false,
checkResult: func(addrs []string) bool {
return len(addrs) == 1 && addrs[0] == "[::1]:8080"
},
},
{
name: "localhost hostname",
address: "localhost:8080",
wantErr: false,
checkResult: func(addrs []string) bool {
// localhost should resolve to at least one address
return len(addrs) >= 1
},
},
{
name: "invalid address",
address: "invalid-no-port",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
addrs, err := resolveHostnameAddrs(tt.address)
if (err != nil) != tt.wantErr {
t.Errorf("resolveHostnameAddrs() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.checkResult != nil {
if !tt.checkResult(addrs) {
t.Errorf("resolveHostnameAddrs() returned unexpected result: %v", addrs)
}
}
})
}
}
func TestResolveHostnameIPs(t *testing.T) {
tests := []struct {
name string
address string
wantErr bool
checkResult func([]string) bool
}{
{
name: "IPv4 address",
address: "127.0.0.1:8080",
wantErr: false,
checkResult: func(ips []string) bool {
return len(ips) == 1 && ips[0] == "127.0.0.1"
},
},
{
name: "IPv6 address",
address: "[::1]:8080",
wantErr: false,
checkResult: func(ips []string) bool {
return len(ips) == 1 && ips[0] == "::1"
},
},
{
name: "localhost hostname",
address: "localhost:8080",
wantErr: false,
checkResult: func(ips []string) bool {
// localhost should resolve to at least one address
// On most systems, it resolves to both 127.0.0.1 and ::1
return len(ips) >= 1
},
},
{
name: "empty host",
address: ":8080",
wantErr: false,
checkResult: func(ips []string) bool {
return len(ips) == 1 && ips[0] == ""
},
},
{
name: "invalid address",
address: "invalid-no-port",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ips, err := ResolveHostnameIPs(tt.address)
if (err != nil) != tt.wantErr {
t.Errorf("ResolveHostnameIPs() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.checkResult != nil {
if !tt.checkResult(ips) {
t.Errorf("ResolveHostnameIPs() returned unexpected result: %v", ips)
}
}
})
}
}
func TestNewMultiAddrListener(t *testing.T) {
tests := []struct {
name string
address string
wantErr bool
}{
{
name: "IPv4 loopback",
address: "127.0.0.1:0",
wantErr: false,
},
{
name: "IPv6 loopback",
address: "[::1]:0",
wantErr: false,
},
{
name: "localhost with port",
address: "localhost:0",
wantErr: false,
},
{
name: "invalid hostname",
address: "this-hostname-should-not-exist-12345.invalid:8080",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ln, err := NewMultiAddrListener("tcp", tt.address)
if (err != nil) != tt.wantErr {
t.Errorf("NewMultiAddrListener() error = %v, wantErr %v", err, tt.wantErr)
return
}
if ln != nil {
defer ln.Close()
// Try to connect to verify listener is working
addr := ln.Addr().String()
go func() {
conn, err := net.Dial("tcp", addr)
if err != nil {
return
}
conn.Close()
}()
// Accept connection with timeout
type result struct {
conn net.Conn
err error
}
ch := make(chan result, 1)
go func() {
conn, err := ln.Accept()
ch <- result{conn, err}
}()
select {
case res := <-ch:
if res.err != nil {
t.Errorf("Failed to accept connection: %v", res.err)
}
if res.conn != nil {
res.conn.Close()
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for connection")
}
}
})
}
}
func TestNewMultiAddrTLSListener(t *testing.T) {
// Create a simple test certificate
getCertFunc := func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.X509KeyPair([]byte(testCert), []byte(testKey))
return &cert, err
}
ln, err := NewMultiAddrTLSListener("tcp", "127.0.0.1:0", getCertFunc)
if err != nil {
t.Fatalf("NewMultiAddrTLSListener() error = %v", err)
}
defer ln.Close()
addr := ln.Addr().String()
// Try to connect with TLS
go func() {
conn, err := tls.Dial("tcp", addr, &tls.Config{
InsecureSkipVerify: true,
})
if err != nil {
return
}
conn.Write([]byte("test"))
conn.Close()
}()
// Accept connection
conn, err := ln.Accept()
if err != nil {
t.Fatalf("Failed to accept TLS connection: %v", err)
}
defer conn.Close()
buf := make([]byte, 100)
_, err = io.ReadAtLeast(conn, buf, 4)
if err != nil {
t.Errorf("Failed to read from TLS connection: %v", err)
}
}
// Test certificate and key for TLS tests
const testCert = `-----BEGIN CERTIFICATE-----
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
6MF9+Yw1Yy0t
-----END CERTIFICATE-----`
const testKey = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49
AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q
EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
-----END EC PRIVATE KEY-----`
+32 -16
View File
@@ -16,8 +16,8 @@ package webui
import (
"fmt"
"net"
"net/http"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/filesystem"
@@ -28,7 +28,6 @@ import (
// ServerConfig holds the server configuration
type ServerConfig struct {
ListenAddr string
Gateways []string // S3 API gateways
AdminGateways []string // Admin API gateways (defaults to Gateways if empty)
Region string
@@ -73,11 +72,11 @@ func NewServer(cfg *ServerConfig, opts ...Option) *Server {
opt(server)
}
fmt.Printf("initializing web dashboard\n")
server.setupMiddleware()
server.setupRoutes()
fmt.Printf("initializing web dashboard on %s\n", cfg.ListenAddr)
return server
}
@@ -124,24 +123,41 @@ func (s *Server) handleGetGateways(c *fiber.Ctx) error {
})
}
// Serve starts the server
func (s *Server) Serve() error {
addr := strings.TrimSpace(s.config.ListenAddr)
if addr == "" {
return fmt.Errorf("webui: listen address is required")
// ServeMultiPort creates listeners for multiple address specifications and serves
// on all of them simultaneously. This supports listening on multiple addresses.
func (s *Server) ServeMultiPort(ports []string) error {
if len(ports) == 0 {
return fmt.Errorf("no addresses specified")
}
// Check if TLS is configured
if s.CertStorage != nil {
ln, err := utils.NewTLSListener(s.app.Config().Network, addr, s.CertStorage.GetCertificate)
if err != nil {
return err
// Multiple addresses - create listeners for each
var listeners []net.Listener
for _, addrSpec := range ports {
var ln net.Listener
var err error
if s.CertStorage != nil {
ln, err = utils.NewMultiAddrTLSListener(s.app.Config().Network, addrSpec, s.CertStorage.GetCertificate)
} else {
ln, err = utils.NewMultiAddrListener(s.app.Config().Network, addrSpec)
}
return s.app.Listener(ln)
if err != nil {
return fmt.Errorf("failed to bind webui listener %s: %w", addrSpec, err)
}
listeners = append(listeners, ln)
}
return s.app.Listen(addr)
if len(listeners) == 0 {
return fmt.Errorf("failed to create any webui listeners")
}
// Combine all listeners
finalListener := utils.NewMultiListener(listeners...)
return s.app.Listener(finalListener)
}
// Shutdown gracefully shuts down the server