mirror of
https://github.com/versity/versitygw.git
synced 2026-08-17 12:46:23 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user