diff --git a/cmd/versitygw/gateway_test.go b/cmd/versitygw/gateway_test.go index ed13c5e5..0c01ac9f 100644 --- a/cmd/versitygw/gateway_test.go +++ b/cmd/versitygw/gateway_test.go @@ -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) + } + }) + } +} diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index c50f102f..dc8ffefb 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -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 : or :", - EnvVars: []string{"VGW_PORT"}, - Value: ":7070", - Destination: &port, - Aliases: []string{"p"}, + &cli.StringSliceFlag{ + Name: "port", + Usage: "gateway listen address : or : (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 : or :", - EnvVars: []string{"VGW_ADMIN_PORT"}, - Destination: &admPort, - Aliases: []string{"ap"}, + &cli.StringSliceFlag{ + Name: "admin-port", + Usage: "gateway admin server listen address : or : (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://:", 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 diff --git a/extra/example.conf b/extra/example.conf index b4a60a89..0251d9d3 100644 --- a/extra/example.conf +++ b/extra/example.conf @@ -60,9 +60,15 @@ ROOT_SECRET_ACCESS_KEY= # This option can use either the form : which will listen only # on the network interface that matches the IP on the specified port, or # : 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 spec can either be IP dotted notation or a resolvable hostname. # The 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 diff --git a/s3api/admin-server.go b/s3api/admin-server.go index 2712c832..b81a706b 100644 --- a/s3api/admin-server.go +++ b/s3api/admin-server.go @@ -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 diff --git a/s3api/server.go b/s3api/server.go index 9d283c64..dca839df 100644 --- a/s3api/server.go +++ b/s3api/server.go @@ -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 diff --git a/s3api/server_test.go b/s3api/server_test.go index 1b21eae5..c1358753 100644 --- a/s3api/server_test.go +++ b/s3api/server_test.go @@ -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) } }) diff --git a/s3api/utils/multi_listener.go b/s3api/utils/multi_listener.go new file mode 100644 index 00000000..2c394a59 --- /dev/null +++ b/s3api/utils/multi_listener.go @@ -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 +} diff --git a/s3api/utils/multi_listener_test.go b/s3api/utils/multi_listener_test.go new file mode 100644 index 00000000..cc097c43 --- /dev/null +++ b/s3api/utils/multi_listener_test.go @@ -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-----` diff --git a/webui/webserver.go b/webui/webserver.go index aae23724..0184a8a6 100644 --- a/webui/webserver.go +++ b/webui/webserver.go @@ -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