From 20939bd7b49b17e44b5f9f2952718321d675a424 Mon Sep 17 00:00:00 2001 From: Ben McClelland Date: Thu, 21 May 2026 16:44:12 -0700 Subject: [PATCH] feat: extract gateway runtime into embeddable package Move the runGateway implementation from cmd/versitygw/main.go into a new embedgw package, exposing RunVersityGW(ctx, Backend, *Config) and a Config struct. This allows external applications to embed and run the VersityGW S3 gateway as a library. --- cmd/versitygw/gateway_test.go | 112 --- cmd/versitygw/main.go | 1245 ++------------------------ cmd/versitygw/signal.go | 8 +- embedgw/embedgw.go | 1533 +++++++++++++++++++++++++++++++++ embedgw/embedgw_test.go | 131 +++ 5 files changed, 1750 insertions(+), 1279 deletions(-) create mode 100644 embedgw/embedgw.go create mode 100644 embedgw/embedgw_test.go diff --git a/cmd/versitygw/gateway_test.go b/cmd/versitygw/gateway_test.go index 6d2f3bfc..929b7733 100644 --- a/cmd/versitygw/gateway_test.go +++ b/cmd/versitygw/gateway_test.go @@ -112,115 +112,3 @@ 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 c13b8ea7..8b2d8355 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -18,25 +18,14 @@ import ( "context" "fmt" "log" - "net" "net/http" _ "net/http/pprof" - "net/url" "os" - "strconv" - "strings" "github.com/urfave/cli/v2" - "github.com/versity/versitygw/auth" "github.com/versity/versitygw/backend" - "github.com/versity/versitygw/debuglogger" - "github.com/versity/versitygw/metrics" - "github.com/versity/versitygw/s3api" - "github.com/versity/versitygw/s3api/middlewares" + "github.com/versity/versitygw/embedgw" "github.com/versity/versitygw/s3api/utils" - "github.com/versity/versitygw/s3event" - "github.com/versity/versitygw/s3log" - "github.com/versity/versitygw/webui" ) var ( @@ -154,7 +143,7 @@ to access the supported backend storage as if it was a native S3 service. 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", + Copyright: "Copyright (c) 2023-2026 Versity Software", Before: func(ctx *cli.Context) error { // Initialize global variables from context (including default values) ports = ctx.StringSlice("port") @@ -783,174 +772,59 @@ func initFlags() []cli.Flag { } func runGateway(ctx context.Context, be backend.Backend) error { - if rootUserAccess == "" || rootUserSecret == "" { - return fmt.Errorf("root user access and secret key must be provided") + if pprof != "" { + // Listen on the specified address for pprof debug endpoints. + // Point a browser to http:///debug/pprof/ + go func() { + log.Printf("pprof: listening on %s", pprof) + if err := http.ListenAndServe(pprof, nil); err != nil { + log.Printf("pprof: server exited: %v", err) + } + }() } - err := validateWebUIPathPrefix("--webui-path-prefix", webuiPathPrefix) - if err != nil { - return err - } - - if maxConnections < 1 { - return fmt.Errorf("max-connections must be positive") - } - if maxRequests < 1 { - return fmt.Errorf("max-requests must be positive") - } - if maxRequests > maxConnections { - log.Printf("WARNING: max-requests (%d) exceeds max-connections (%d) which could allow for gateway to panic before throttling requests", - maxRequests, maxConnections) - } - if mpMaxParts < 1 { - return fmt.Errorf("mp-max-parts must be positive") - } if copyObjectThreshold < 1 { return fmt.Errorf("copy-object-threshold must be positive") } - // Ensure we have at least one port specified - if len(ports) == 0 { - return fmt.Errorf("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 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. - corsAllowOrigin = "*" - webuiScheme := "http" - if !webuiNoTLS && (strings.TrimSpace(webuiCertFile) != "" || strings.TrimSpace(certFile) != "") { - webuiScheme = "https" - } - - // 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 - 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)) - } - } - } - 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) - } - - 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 - err = validatePortConflicts(ports, admPorts, webuiPorts) - if err != nil { - return err - } - - err = validateWebUIPathPrefix("--webui-s3-prefix", webuiS3Prefix) - if err != nil { - return err - } - - utils.SetBucketNameValidationStrict(!disableStrictBucketNames) - - var parsedSocketPerm os.FileMode - if socketPerm != "" { - perm, err := strconv.ParseUint(socketPerm, 8, 32) - if err != nil { - return fmt.Errorf("invalid --socket-perm value %q: must be an octal integer (e.g. '0660'): %w", socketPerm, err) - } - parsedSocketPerm = os.FileMode(perm) - } - - if pprof != "" { - // listen on specified port for pprof debug - // point browser to http:///debug/pprof/ - go func() { - log.Fatal(http.ListenAndServe(pprof, nil)) - }() - } - - opts := []s3api.Option{ - s3api.WithConcurrencyLimiter(maxConnections, maxRequests), - s3api.WithMpMaxParts(mpMaxParts), - } - if socketPerm != "" { - opts = append(opts, s3api.WithSocketPerm(parsedSocketPerm)) - } - if corsAllowOrigin != "" { - opts = append(opts, s3api.WithCORSAllowOrigin(corsAllowOrigin)) - } - - if certFile != "" || keyFile != "" { - if certFile == "" { - return fmt.Errorf("TLS key specified without cert file") - } - if keyFile == "" { - return fmt.Errorf("TLS cert specified without key file") - } - - cs := utils.NewCertStorage() - err := cs.SetCertificate(certFile, keyFile) - if err != nil { - return fmt.Errorf("tls: load certs: %v", err) - } - opts = append(opts, s3api.WithTLS(cs)) - } - if len(admPorts) == 0 { - opts = append(opts, s3api.WithAdminServer()) - } - if quiet { - opts = append(opts, s3api.WithQuiet()) - } - if healthPath != "" { - opts = append(opts, s3api.WithHealth(healthPath)) - } - if readonly { - opts = append(opts, s3api.WithReadOnly()) - } - if virtualDomain != "" { - opts = append(opts, s3api.WithHostStyle(virtualDomain)) - } - if keepAlive { - opts = append(opts, s3api.WithKeepAlive()) - } - if disableACLs { - opts = append(opts, s3api.WithDisableACL()) - } - if debug { - debuglogger.SetDebugEnabled() - } - if iamDebug { - debuglogger.SetIAMDebugEnabled() - } - - iam, err := auth.New(&auth.Opts{ - RootAccount: auth.Account{ - Access: rootUserAccess, - Secret: rootUserSecret, - Role: auth.RoleAdmin, - }, - Dir: iamDir, + return embedgw.RunVersityGW(ctx, be, &embedgw.Config{ + RootUserAccess: rootUserAccess, + RootUserSecret: rootUserSecret, + Region: region, + Ports: ports, + AdminPorts: admPorts, + MaxConnections: maxConnections, + MaxRequests: maxRequests, + AdminMaxConnections: adminMaxConnections, + AdminMaxRequests: adminMaxRequests, + MultipartMaxParts: mpMaxParts, + CertFile: certFile, + KeyFile: keyFile, + AdminCertFile: admCertFile, + AdminKeyFile: admKeyFile, + CORSAllowOrigin: corsAllowOrigin, + Debug: debug, + IAMDebug: iamDebug, + Quiet: quiet, + Readonly: readonly, + KeepAlive: keepAlive, + DisableACLs: disableACLs, + DisableStrictBucketNames: disableStrictBucketNames, + VirtualDomain: virtualDomain, + HealthPath: healthPath, + SocketPerm: socketPerm, + IAMDir: iamDir, LDAPServerURL: ldapURL, LDAPBindDN: ldapBindDN, LDAPPassword: ldapPassword, LDAPQueryBase: ldapQueryBase, LDAPObjClasses: ldapObjClasses, - LDAPAccessAtr: ldapAccessAtr, - LDAPSecretAtr: ldapSecAtr, - LDAPRoleAtr: ldapRoleAtr, - LDAPUserIdAtr: ldapUserIdAtr, - LDAPGroupIdAtr: ldapGroupIdAtr, - LDAPProjectIdAtr: ldapProjectIdAtr, + LDAPAccessAttr: ldapAccessAtr, + LDAPSecretAttr: ldapSecAtr, + LDAPRoleAttr: ldapRoleAtr, + LDAPUserIDAttr: ldapUserIdAtr, + LDAPGroupIDAttr: ldapGroupIdAtr, + LDAPProjectIDAttr: ldapProjectIdAtr, LDAPTLSSkipVerify: ldapTLSSkipVerify, VaultEndpointURL: vaultEndpointURL, VaultNamespace: vaultNamespace, @@ -960,1007 +834,52 @@ func runGateway(ctx context.Context, be backend.Backend) error { VaultAuthNamespace: vaultAuthNamespace, VaultMountPath: vaultMountPath, VaultRootToken: vaultRootToken, - VaultRoleId: vaultRoleId, + VaultRoleID: vaultRoleId, VaultRoleSecret: vaultRoleSecret, VaultServerCert: vaultServerCert, VaultClientCert: vaultClientCert, VaultClientCertKey: vaultClientCertKey, - S3Access: s3IamAccess, - S3Secret: s3IamSecret, - S3Region: s3IamRegion, - S3Bucket: s3IamBucket, - S3Endpoint: s3IamEndpoint, - S3DisableSSlVerfiy: s3IamSslNoVerify, - CacheDisable: iamCacheDisable, - CacheTTL: iamCacheTTL, - CachePrune: iamCachePrune, + S3IAMAccess: s3IamAccess, + S3IAMSecret: s3IamSecret, + S3IAMRegion: s3IamRegion, + S3IAMBucket: s3IamBucket, + S3IAMEndpoint: s3IamEndpoint, + S3IAMDisableSSLVerify: s3IamSslNoVerify, + IAMCacheDisable: iamCacheDisable, + IAMCacheTTL: iamCacheTTL, + IAMCachePrune: iamCachePrune, IpaHost: ipaHost, IpaVaultName: ipaVaultName, IpaUser: ipaUser, IpaPassword: ipaPassword, IpaInsecure: ipaInsecure, + AccessLog: accessLog, + LogWebhookURL: logWebhookURL, + AdminLogFile: adminLogFile, + MetricsService: metricsService, + StatsdServers: statsdServers, + DogstatsServers: dogstatsServers, + KafkaURL: kafkaURL, + KafkaTopic: kafkaTopic, + KafkaKey: kafkaKey, + NatsURL: natsURL, + NatsTopic: natsTopic, + RabbitmqURL: rabbitmqURL, + RabbitmqExchange: rabbitmqExchange, + RabbitmqRoutingKey: rabbitmqRoutingKey, + EventWebhookURL: eventWebhookURL, + EventConfigFilePath: eventConfigFilePath, + WebuiPorts: webuiPorts, + WebuiCertFile: webuiCertFile, + WebuiKeyFile: webuiKeyFile, + WebuiNoTLS: webuiNoTLS, + WebuiGateways: webuiGateways, + WebuiAdminGateways: webuiAdminGateways, + WebuiPathPrefix: webuiPathPrefix, + WebuiS3Prefix: webuiS3Prefix, + SigHup: sigHup, + Version: Version, + Build: Build, + BuildTime: BuildTime, }) - if err != nil { - return fmt.Errorf("setup iam: %w", err) - } - - loggers, err := s3log.InitLogger(&s3log.LogConfig{ - LogFile: accessLog, - WebhookURL: logWebhookURL, - AdminLogFile: adminLogFile, - }) - if err != nil { - return fmt.Errorf("setup logger: %w", err) - } - - metricsManager, err := metrics.NewManager(ctx, metrics.Config{ - ServiceName: metricsService, - StatsdServers: statsdServers, - DogStatsdServers: dogstatsServers, - }) - if err != nil { - return fmt.Errorf("init metrics manager: %w", err) - } - - evSender, err := s3event.InitEventSender(&s3event.EventConfig{ - KafkaURL: kafkaURL, - KafkaTopic: kafkaTopic, - KafkaTopicKey: kafkaKey, - NatsURL: natsURL, - NatsTopic: natsTopic, - RabbitmqURL: rabbitmqURL, - RabbitmqExchange: rabbitmqExchange, - RabbitmqRoutingKey: rabbitmqRoutingKey, - WebhookURL: eventWebhookURL, - FilterConfigFilePath: eventConfigFilePath, - }) - if err != nil { - return fmt.Errorf("init bucket event notifications: %w", err) - } - - if webuiS3Prefix != "" { - s3SSLEnabled := certFile != "" - s3AdmSSLEnabled := s3SSLEnabled - if len(admPorts) > 0 { - s3AdmSSLEnabled = admCertFile != "" - } - - var s3WebGateways []string - if len(webuiGateways) > 0 { - validGateways, err := validateGatewayURLs(webuiGateways, "webui gateway") - if err != nil { - return err - } - s3WebGateways = validGateways - } else { - for _, p := range ports { - urls, err := buildServiceURLs(p, s3SSLEnabled) - if err != nil { - return fmt.Errorf("webui-s3-prefix: build gateway URLs: %w", err) - } - s3WebGateways = append(s3WebGateways, urls...) - } - sortGatewayURLs(s3WebGateways) - } - - s3WebAdminGateways := s3WebGateways - if len(webuiAdminGateways) > 0 { - validAdminGateways, err := validateGatewayURLs(webuiAdminGateways, "webui admin gateway") - if err != nil { - return err - } - s3WebAdminGateways = validAdminGateways - } else if len(admPorts) > 0 { - s3WebAdminGateways = nil - for _, admPort := range admPorts { - urls, err := buildServiceURLs(admPort, s3AdmSSLEnabled) - if err != nil { - return fmt.Errorf("webui-s3-prefix: build admin gateway URLs: %w", err) - } - s3WebAdminGateways = append(s3WebAdminGateways, urls...) - } - sortGatewayURLs(s3WebAdminGateways) - } - - opts = append(opts, s3api.WithWebUI(webuiS3Prefix, &webui.ServerConfig{ - Gateways: s3WebGateways, - AdminGateways: s3WebAdminGateways, - Region: region, - })) - } - - srv, err := s3api.New(be, middlewares.RootUserConfig{ - Access: rootUserAccess, - Secret: rootUserSecret, - }, region, iam, loggers.S3Logger, loggers.AdminLogger, evSender, metricsManager, opts...) - if err != nil { - return fmt.Errorf("init gateway: %v", err) - } - - var admSrv *s3api.S3AdminServer - - if len(admPorts) > 0 { - var opts []s3api.AdminOpt - - if adminMaxConnections < 1 { - return fmt.Errorf("admin-max-connections must be positive") - } - if adminMaxRequests < 1 { - return fmt.Errorf("admin-max-requests must be positive") - } - if adminMaxRequests > adminMaxConnections { - log.Printf("WARNING: admin-max-requests (%d) exceeds admin-max-connections (%d) which could allow for gateway to panic before throttling requests", - adminMaxRequests, adminMaxConnections) - } - - opts = []s3api.AdminOpt{ - s3api.WithAdminConcurrencyLimiter(adminMaxConnections, adminMaxRequests), - } - - if corsAllowOrigin != "" { - opts = append(opts, s3api.WithAdminCORSAllowOrigin(corsAllowOrigin)) - } - - if admCertFile != "" || admKeyFile != "" { - if admCertFile == "" { - return fmt.Errorf("TLS key specified without cert file") - } - if admKeyFile == "" { - return fmt.Errorf("TLS cert specified without key file") - } - - cs := utils.NewCertStorage() - err = cs.SetCertificate(admCertFile, admKeyFile) - if err != nil { - return fmt.Errorf("tls: load certs: %v", err) - } - opts = append(opts, s3api.WithAdminSrvTLS(cs)) - } - if quiet { - opts = append(opts, s3api.WithAdminQuiet()) - } - if debug { - opts = append(opts, s3api.WithAdminDebug()) - } - if socketPerm != "" { - opts = append(opts, s3api.WithAdminSocketPerm(parsedSocketPerm)) - } - - 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 len(webuiPorts) > 0 { - // Validate all webui addresses - for _, addr := range webuiPorts { - if utils.IsUnixSocketPath(addr) { - continue - } - _, 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 - if !webuiNoTLS { - // WebUI can either use explicitly provided TLS files or reuse the - // gateway's TLS files by default. - webTLSCert = webuiCertFile - webTLSKey = webuiKeyFile - if webTLSCert == "" && webTLSKey == "" { - webTLSCert = certFile - webTLSKey = keyFile - } - if webTLSCert != "" || webTLSKey != "" { - if webTLSCert == "" { - return fmt.Errorf("webui TLS key specified without cert file") - } - if webTLSKey == "" { - return fmt.Errorf("webui TLS cert specified without key file") - } - webuiSSLEnabled = true - - cs := utils.NewCertStorage() - err := cs.SetCertificate(webTLSCert, webTLSKey) - if err != nil { - return fmt.Errorf("tls: load certs: %v", err) - } - - webOpts = append(webOpts, webui.WithTLS(cs)) - } - } - - sslEnabled := certFile != "" - admSSLEnabled := sslEnabled - if len(admPorts) > 0 { - admSSLEnabled = admCertFile != "" - } - - var gateways []string - if len(webuiGateways) > 0 { - // Use explicitly provided gateway URLs if specified - // Validate explicitly provided URLs - validGateways, err := validateGatewayURLs(webuiGateways, "webui gateway") - if err != nil { - return err - } - gateways = validGateways - } else { - // Auto-detect from configured ports - 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...) - } - // Sort so localhost/127.0.0.1 URLs appear last - sortGatewayURLs(gateways) - } - - adminGateways := gateways - if len(webuiAdminGateways) > 0 { - // Validate explicitly provided admin gateway URLs - validAdminGateways, err := validateGatewayURLs(webuiAdminGateways, "webui admin gateway") - if err != nil { - return err - } - adminGateways = validAdminGateways - } else if len(admPorts) > 0 { - // Auto-detect from configured admin ports - 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...) - } - // Sort so localhost/127.0.0.1 URLs appear last - sortGatewayURLs(adminGateways) - } - - if quiet { - webOpts = append(webOpts, webui.WithQuiet()) - } - if webuiPathPrefix != "" { - webOpts = append(webOpts, webui.WithPathPrefix(webuiPathPrefix)) - } - if socketPerm != "" { - webOpts = append(webOpts, webui.WithSocketPerm(parsedSocketPerm)) - } - - webSrv = webui.NewServer(&webui.ServerConfig{ - Gateways: gateways, - AdminGateways: adminGateways, - Region: region, - }, webOpts...) - } - - if !quiet { - printBanner(ports, admPorts, certFile != "" || keyFile != "", admCertFile != "" || admKeyFile != "", webuiPorts, webuiSSLEnabled, webuiPathPrefix, webuiS3Prefix) - } - - servers := 1 - if len(admPorts) > 0 { - servers++ - } - if len(webuiPorts) > 0 { - servers++ - } - - c := make(chan error, servers) - go func() { c <- srv.ServeMultiPort(ports) }() - if len(admPorts) > 0 { - go func() { c <- admSrv.ServeMultiPort(admPorts) }() - } - if len(webuiPorts) > 0 { - go func() { c <- webSrv.ServeMultiPort(webuiPorts) }() - } - - // for/select blocks until shutdown -Loop: - for { - select { - case <-ctx.Done(): - break Loop - case err = <-c: - break Loop - case <-sigHup: - if loggers.S3Logger != nil { - err = loggers.S3Logger.HangUp() - if err != nil { - err = fmt.Errorf("HUP s3 logger: %w", err) - break Loop - } - } - if loggers.AdminLogger != nil { - err = loggers.AdminLogger.HangUp() - if err != nil { - err = fmt.Errorf("HUP admin logger: %w", err) - break Loop - } - } - if certFile != "" && keyFile != "" { - err = srv.CertStorage.SetCertificate(certFile, keyFile) - if err != nil { - debuglogger.InternalError(fmt.Errorf("srv cert reload failed: %w", err)) - } else { - fmt.Printf("srv cert reloaded (cert: %s, key: %s)\n", certFile, keyFile) - } - } - 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)) - } else { - fmt.Printf("admSrv cert reloaded (cert: %s, key: %s)\n", admCertFile, admKeyFile) - } - } - 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)) - } else { - fmt.Printf("webSrv cert reloaded (cert: %s, key: %s)\n", webTLSCert, webTLSKey) - } - } - } - } - saveErr := err - - // first shut down the s3api and admin servers - // as they have dependecy from other modules - err = srv.ShutDown() - if err != nil { - fmt.Fprintf(os.Stderr, "shutdown api server: %v\n", err) - } - - if admSrv != nil { - err := admSrv.Shutdown() - if err != nil { - fmt.Fprintf(os.Stderr, "shutdown admin server: %v\n", err) - } - } - - if webSrv != nil { - err := webSrv.Shutdown() - if err != nil { - fmt.Fprintf(os.Stderr, "shutdown webui server: %v\n", err) - } - } - - be.Shutdown() - - err = iam.Shutdown() - if err != nil { - fmt.Fprintf(os.Stderr, "shutdown iam: %v\n", err) - } - - if loggers.S3Logger != nil { - err := loggers.S3Logger.Shutdown() - if err != nil { - fmt.Fprintf(os.Stderr, "shutdown s3 logger: %v\n", err) - } - } - if loggers.AdminLogger != nil { - err := loggers.AdminLogger.Shutdown() - if err != nil { - fmt.Fprintf(os.Stderr, "shutdown admin logger: %v\n", err) - } - } - - if evSender != nil { - err := evSender.Close() - if err != nil { - fmt.Fprintf(os.Stderr, "close event sender: %v\n", err) - } - } - - if metricsManager != nil { - metricsManager.Close() - } - - return saveErr -} - -func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs []string, webuiSsl bool, webuiPathPrefix string, webuiS3Prefix string) { - if len(ports) == 0 { - fmt.Fprintf(os.Stderr, "No ports specified\n") - return - } - - // Collect all interfaces for all ports - var allInterfaces []string - var allPorts []string - interfaceMap := make(map[string]bool) // deduplicate - - for _, portSpec := range ports { - if utils.IsUnixSocketPath(portSpec) { - allPorts = append(allPorts, portSpec) - if !interfaceMap[portSpec] { - interfaceMap[portSpec] = true - allInterfaces = append(allInterfaces, portSpec) - } - continue - } - interfaces, err := getMatchingIPs(portSpec) - if err != nil { - 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 { - if utils.IsUnixSocketPath(admPort) { - if !admInterfaceMap[admPort] { - admInterfaceMap[admPort] = true - allAdmInterfaces = append(allAdmInterfaces, admPort) - } - continue - } - 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) - } - } - } - - title := "VersityGW" - version := fmt.Sprintf("Version %v, Build %v", Version, Build) - urls := []string{} - - // Build URLs for all listening addresses - for _, addrPort := range allInterfaces { - if utils.IsUnixSocketPath(addrPort) { - urls = append(urls, "unix:"+addrPort) - continue - } - 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", hostPort) - } - urls = append(urls, url) - } - - // Determine bound host description - var boundHost string - if len(ports) == 1 { - if utils.IsUnixSocketPath(ports[0]) { - boundHost = fmt.Sprintf("(unix socket: %s)", ports[0]) - } else { - 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) - } - - lines := []string{ - centerText(title), - centerText(version), - centerText(boundHost), - centerText(""), - } - - if len(allAdmInterfaces) > 0 { - lines = append(lines, - leftText("S3 service listening on:"), - ) - } else { - lines = append(lines, - leftText("Admin/S3 service listening on:"), - ) - } - - for _, url := range urls { - lines = append(lines, leftText(" "+url)) - } - - if len(allAdmInterfaces) > 0 { - lines = append(lines, - centerText(""), - leftText("Admin service listening on:"), - ) - - for _, addrPort := range allAdmInterfaces { - if utils.IsUnixSocketPath(addrPort) { - lines = append(lines, leftText(" unix:"+addrPort)) - continue - } - 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", hostPort) - } - lines = append(lines, leftText(" "+url)) - } - } - - // 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 - } - if utils.IsUnixSocketPath(webuiAddr) { - if !webInterfaceMap[webuiAddr] { - webInterfaceMap[webuiAddr] = true - allWebInterfaces = append(allWebInterfaces, 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) - } - } - } - - if len(allWebInterfaces) > 0 { - lines = append(lines, - centerText(""), - leftText("WebUI listening on:"), - ) - for _, addrPort := range allWebInterfaces { - if utils.IsUnixSocketPath(addrPort) { - lines = append(lines, leftText(" unix:"+addrPort)) - continue - } - 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+webuiPathPrefix)) - } - } - } - - if webuiS3Prefix != "" { - lines = append(lines, - centerText(""), - leftText("WebUI embedded on S3 service at:"), - ) - for _, addrPort := range allInterfaces { - ip, prt, err := net.SplitHostPort(addrPort) - if err != nil { - continue - } - hostPort := net.JoinHostPort(ip, prt) - url := fmt.Sprintf("http://%s", hostPort) - if ssl { - url = fmt.Sprintf("https://%s", hostPort) - } - lines = append(lines, leftText(" "+url+webuiS3Prefix)) - } - } - - // Print the top border - fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐") - - // Print each line - for _, line := range lines { - fmt.Printf("│%-*s│\n", columnWidth-2, line) - } - - // Print the bottom border - fmt.Println("└" + strings.Repeat("─", columnWidth-2) + "┘") -} - -// 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) { - if utils.IsUnixSocketPath(spec) { - // Unix socket paths have no IP addresses; return the path itself as an identifier. - return []string{spec}, nil - } - - ips, err := utils.ResolveHostnameIPs(spec) - if err != nil { - return nil, fmt.Errorf("resolve hostname: %v", err) - } - - // If empty host (e.g., ":8080"), enumerate all local interfaces - if len(ips) == 1 && ips[0] == "" { - return getAllLocalIPs() - } - - // 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) - } - - return result, nil -} - -// getAllLocalIPs returns all non-link-local IP addresses from local interfaces -func getAllLocalIPs() ([]string, error) { - var result []string - - interfaces, err := net.Interfaces() - if err != nil { - return nil, err - } - - for _, iface := range interfaces { - addrs, err := iface.Addrs() - if err != nil { - continue - } - - for _, addr := range addrs { - ipAddr, _, err := net.ParseCIDR(addr.String()) - if err != nil { - continue - } - - if ipAddr.IsLinkLocalUnicast() || ipAddr.IsInterfaceLocalMulticast() || ipAddr.IsLinkLocalMulticast() { - continue - } - - result = append(result, ipAddr.String()) - } - } - - return result, nil -} - -func buildServiceURLs(spec string, ssl bool) ([]string, error) { - if utils.IsUnixSocketPath(spec) { - // UNIX socket paths cannot be expressed as HTTP(S) URLs for WebUI gateways; - // skip them silently. - return nil, nil - } - - interfaces, err := getMatchingIPs(spec) - if err != nil { - return nil, err - } - _, prt, err := net.SplitHostPort(spec) - if err != nil { - return nil, fmt.Errorf("parse address/port: %w", err) - } - if len(interfaces) == 0 { - interfaces = []string{"localhost"} - } - - scheme := "http" - if ssl { - scheme = "https" - } - urls := make([]string, 0, len(interfaces)) - for _, ip := range interfaces { - urls = append(urls, fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(ip, prt))) - } - return urls, nil -} - -// isLocalhost checks if a URL contains a localhost address -func isLocalhost(url string) bool { - return strings.Contains(url, "localhost") || - strings.Contains(url, "127.0.0.1") || - strings.Contains(url, "[::1]") -} - -// validateGatewayURLs validates a list of gateway URLs and returns only valid ones. -// It prints warnings for invalid URLs and returns an error if the input list is non-empty -// but contains no valid URLs after filtering. -func validateGatewayURLs(urls []string, urlType string) ([]string, error) { - if len(urls) == 0 { - return urls, nil - } - - var validURLs []string - for _, urlStr := range urls { - // Skip empty strings - if strings.TrimSpace(urlStr) == "" { - continue - } - - parsedURL, err := url.Parse(urlStr) - if err != nil { - fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: %v\n", urlType, urlStr, err) - continue - } - - // Ensure the URL has a scheme (http or https) - if parsedURL.Scheme == "" { - fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: missing scheme (must be http:// or https://)\n", urlType, urlStr) - continue - } - - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: unsupported scheme %q (must be http or https)\n", urlType, urlStr, parsedURL.Scheme) - continue - } - - // Ensure the URL has a host - if parsedURL.Host == "" { - fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: missing host\n", urlType, urlStr) - continue - } - - validURLs = append(validURLs, urlStr) - } - - if len(validURLs) == 0 { - return nil, fmt.Errorf("%s URLs specified but none are valid", urlType) - } - - return validURLs, nil -} - -// validateWebUIPathPrefix validates webui path prefix. -// Accepted format is a single path segment like "/ui". -func validateWebUIPathPrefix(option, prefix string) error { - if prefix == "" { - return nil - } - - if strings.TrimSpace(prefix) != prefix { - return fmt.Errorf("invalid %v %q: must not contain leading or trailing whitespace", - option, prefix) - } - - if !strings.HasPrefix(prefix, "/") { - return fmt.Errorf("invalid %v %q: must start with '/' (example: '/ui')", - option, prefix) - } - - if strings.HasSuffix(prefix, "/") { - return fmt.Errorf("invalid %v %q: must not end with '/'", - option, prefix) - } - - if strings.Count(prefix, "/") > 1 { - return fmt.Errorf("invalid %v %q: only a single path segment is allowed (example: '/ui')", - option, prefix) - } - - if strings.ContainsAny(prefix, "?#") { - return fmt.Errorf("invalid %v %q: query strings and fragments are not allowed", - option, prefix) - } - - if strings.Contains(prefix, "\\") { - return fmt.Errorf("invalid %v %q: backslashes are not allowed", - option, prefix) - } - - return nil -} - -// sortGatewayURLs sorts a list of URLs so that localhost and 127.0.0.1 URLs appear last -func sortGatewayURLs(urls []string) { - if len(urls) <= 1 { - return - } - - // Partition URLs into two groups: non-localhost and localhost - var nonLocal []string - var local []string - - for _, url := range urls { - if isLocalhost(url) { - local = append(local, url) - } else { - nonLocal = append(nonLocal, url) - } - } - - // Rebuild the slice with non-localhost first, then localhost - copy(urls, nonLocal) - copy(urls[len(nonLocal):], local) -} - -// 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). -// UNIX socket paths (e.g., "/tmp/gw.sock") are checked for duplicate path conflicts only, -// and do not conflict with TCP port specifications. -// 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 - isUnix bool - portType string // "s3", "admin", or "webui" - } - - var allSpecs []portSpec - - // Collect all port specs - for _, p := range ports { - if utils.IsUnixSocketPath(p) { - allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "s3"}) - continue - } - _, 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 { - if utils.IsUnixSocketPath(p) { - allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "admin"}) - continue - } - _, 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 { - if utils.IsUnixSocketPath(p) { - allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "webui"}) - continue - } - _, 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 - } - - // Unix sockets and TCP ports never conflict with each other; - // only check for duplicate socket paths. - if spec1.isUnix || spec2.isUnix { - if spec1.isUnix && spec2.isUnix && spec1.spec == spec2.spec { - return fmt.Errorf("duplicate unix socket path: --%s %s conflicts with --%s %s", - spec1.portType, spec1.spec, spec2.portType, spec2.spec) - } - continue - } - - // 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 nil -} - -const columnWidth = 70 - -func centerText(text string) string { - padding := max((columnWidth-2-len(text))/2, 0) - return strings.Repeat(" ", padding) + text -} - -func leftText(text string) string { - if len(text) > columnWidth-2 { - return text - } - return text + strings.Repeat(" ", columnWidth-2-len(text)) } diff --git a/cmd/versitygw/signal.go b/cmd/versitygw/signal.go index a92dbd54..9b2c2d30 100644 --- a/cmd/versitygw/signal.go +++ b/cmd/versitygw/signal.go @@ -22,8 +22,8 @@ import ( ) var ( - sigDone = make(chan bool, 1) - sigHup = make(chan bool, 1) + sigDone = make(chan struct{}, 1) + sigHup = make(chan struct{}, 1) ) func setupSignalHandler() { @@ -35,9 +35,9 @@ func setupSignalHandler() { fmt.Fprintf(os.Stderr, "caught signal %v\n", sig) switch sig { case syscall.SIGINT, syscall.SIGTERM: - sigDone <- true + sigDone <- struct{}{} case syscall.SIGHUP: - sigHup <- true + sigHup <- struct{}{} } } }() diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go new file mode 100644 index 00000000..524700ea --- /dev/null +++ b/embedgw/embedgw.go @@ -0,0 +1,1533 @@ +// 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 embedgw provides a high-level entry point for running the VersityGW +// S3 gateway as a library, making it easy to embed the gateway into other +// applications. +// +// Note: only a single gateway instance per process is currently supported. +// Several subsystems (bucket-name validation, debug logging) rely on +// package-level globals that would race if RunVersityGW were called +// concurrently from multiple goroutines. +package embedgw + +import ( + "context" + "fmt" + "log" + "net" + "net/url" + "os" + "strconv" + "strings" + "sync/atomic" + + "github.com/versity/versitygw/auth" + "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/metrics" + "github.com/versity/versitygw/s3api" + "github.com/versity/versitygw/s3api/middlewares" + "github.com/versity/versitygw/s3api/utils" + "github.com/versity/versitygw/s3event" + "github.com/versity/versitygw/s3log" + "github.com/versity/versitygw/webui" +) + +const awsDefaultRegion = "us-east-1" + +// Config holds all configuration options for running the VersityGW gateway. +type Config struct { + // RootUserAccess is the access key ID for the root account. The root + // account is granted full authorization to all API requests after + // authentication. Required. + RootUserAccess string + // RootUserSecret is the secret access key for the root account. Required. + RootUserSecret string + // Region is the AWS region name reported to S3 clients (e.g. "us-east-1"). + // Defaults to "us-east-1" when empty. + Region string + + // Ports is the list of S3 API listening addresses. Each entry can be + // "host:port" to bind a specific interface, or ":port" to bind all + // interfaces. Hostnames are resolved to all matching IPs. UNIX domain + // sockets are supported as absolute or relative paths, or Linux abstract + // namespace sockets prefixed with "@" (e.g. "@versitygw-s3"). Multiple + // entries are supported (e.g. [":7070", "localhost:9090"]). Required. + Ports []string + + // AdminPorts is the list of admin API listening addresses. Accepts the + // same formats as Ports. When empty, the admin API is served on the same + // endpoints as the S3 API. Setting this allows finer-grained firewall + // control over the admin endpoint with optionally separate TLS certs. + AdminPorts []string + + // MaxConnections is the maximum number of concurrent TCP connections + // accepted by the S3 API server. + MaxConnections int + // MaxRequests is the maximum number of concurrent in-flight S3 requests. + // Should not exceed MaxConnections; if it does, a warning is logged. + MaxRequests int + + // AdminMaxConnections is the maximum concurrent TCP connections for the + // separate admin server. Only used when AdminPorts is non-empty. + AdminMaxConnections int + // AdminMaxRequests is the maximum concurrent in-flight requests for the + // admin server. Should not exceed AdminMaxConnections. + AdminMaxRequests int + + // MultipartMaxParts is the maximum number of parts allowed in a single + // multipart upload. The S3 specification allows up to 10,000 parts; + // the default value of 10000 matches the AWS S3 maximum. Clients that + // attempt to upload more parts than this limit receive an error. + MultipartMaxParts int + + // CertFile is the path to the TLS certificate file for the S3 API server. + // Both CertFile and KeyFile must be provided together to enable TLS. + CertFile string + // KeyFile is the path to the TLS private key file for the S3 API server. + KeyFile string + + // AdminCertFile is the path to the TLS certificate for the admin server. + // Both AdminCertFile and AdminKeyFile must be provided together. When + // empty and AdminPorts is set, the admin server runs without TLS. + AdminCertFile string + // AdminKeyFile is the path to the TLS private key for the admin server. + AdminKeyFile string + + // CORSAllowOrigin sets the default Access-Control-Allow-Origin response + // header value applied when no bucket-level CORS configuration exists and + // for all admin API responses. When WebuiPorts is set and this is empty, + // it defaults to "*". For production, set this to a specific origin + // (e.g. "https://webui.example.com") to restrict cross-origin access. + CORSAllowOrigin string + + // Debug enables verbose debug logging to stdout, including details for + // signature verification steps. Not intended for production use. + Debug bool + // IAMDebug enables verbose IAM subsystem debug logging. + IAMDebug bool + // Quiet suppresses per-request summary logging to stdout. + Quiet bool + // Readonly restricts the gateway to read-only S3 operations; all write + // requests are rejected. + Readonly bool + // KeepAlive enables HTTP keep-alive on S3 API connections. + KeepAlive bool + // DisableACLs disables ACL enforcement at the gateway level. All ACL + // headers on requests are ignored and no access control is enforced via + // bucket ACLs. PutBucketAcl returns AccessControlListNotSupported. + // Prefer bucket policies over ACLs when this is enabled. + DisableACLs bool + // DisableStrictBucketNames allows legacy or non-DNS-compliant bucket + // names by skipping strict validation. By default, bucket name validation + // follows the rules described in the AWS S3 documentation. + DisableStrictBucketNames bool + + // VirtualDomain enables virtual-hosted-style bucket addressing. Set to + // the base domain name (e.g. "s3.example.com") so that bucket access uses + // the form "https://.s3.example.com/". Path-style addressing + // remains enabled alongside it. Each bucket typically requires a DNS + // entry pointing to the gateway. + VirtualDomain string + + // HealthPath is the URL path for unauthenticated health-check requests + // (e.g. "/healthz"). The endpoint returns HTTP 200 for GET requests and + // is commonly used by load balancers. Any bucket whose name matches the + // path segment is masked while this is set. + HealthPath string + + // SocketPerm is the octal file-mode string for UNIX domain socket + // permissions (e.g. "0660" for owner+group read/write). Has no effect on + // TCP/IP addresses or Linux abstract "@" namespace sockets. When empty, + // permissions are determined by the process umask. + SocketPerm string + + // IAM Backends + // + // The gateway supports five external IAM backends. At most one may be + // active at a time. When the fields for more than one backend are + // populated, the first match in the following priority order wins: + // + // 1. IAMDir -- local directory + // 2. LDAPServerURL -- LDAP + // 3. S3IAMEndpoint -- S3-backed + // 4. VaultEndpointURL -- HashiCorp Vault + // 5. IpaHost -- FreeIPA + // + // Configuring an IAM backend is optional. When none of the trigger fields + // above are set, the gateway runs in single-account mode: only the root + // account (RootUserAccess/RootUserSecret) exists and the user management + // API is unavailable. + // + // The IAMCache fields below apply to all backends except single-account + // mode. + + // IAMDir enables the local file-based IAM backend. Set to the directory + // path where account files are stored. Account data is plain text + // protected only by filesystem permissions; suitable for development but + // not recommended for production deployments. + IAMDir string + + // LDAP IAM backend. Activated when LDAPServerURL is non-empty. + + // LDAPServerURL is the URL of the LDAP server (e.g. "ldap://ldap.example.com:389"). + LDAPServerURL string + // LDAPBindDN is the distinguished name used to bind to the LDAP server. + LDAPBindDN string + // LDAPPassword is the password for LDAPBindDN. + LDAPPassword string + // LDAPQueryBase is the base DN for user search queries. + LDAPQueryBase string + // LDAPObjClasses is the LDAP object class filter for user entries. + LDAPObjClasses string + // LDAPAccessAttr is the LDAP attribute that holds the S3 access key ID. + LDAPAccessAttr string + // LDAPSecretAttr is the LDAP attribute that holds the S3 secret key. + LDAPSecretAttr string + // LDAPRoleAttr is the LDAP attribute that holds the user role. + LDAPRoleAttr string + // LDAPUserIDAttr is the LDAP attribute that holds the POSIX user ID. + LDAPUserIDAttr string + // LDAPGroupIDAttr is the LDAP attribute that holds the POSIX group ID. + LDAPGroupIDAttr string + // LDAPProjectIDAttr is the LDAP attribute that holds the project ID. + LDAPProjectIDAttr string + // LDAPTLSSkipVerify disables TLS certificate verification for the LDAP + // connection. Use only in development or trusted internal environments. + LDAPTLSSkipVerify bool + + // HashiCorp Vault IAM backend. Activated when VaultEndpointURL is non-empty. + + // VaultEndpointURL is the HashiCorp Vault server URL + // (e.g. "https://vault.example.com:8200"). + VaultEndpointURL string + // VaultNamespace is the Vault namespace to use (Vault Enterprise only). + VaultNamespace string + // VaultSecretStoragePath is the KV secrets engine path where account + // data is stored. + VaultSecretStoragePath string + // VaultSecretStorageNamespace is the Vault namespace for the secrets + // storage path (Vault Enterprise only). + VaultSecretStorageNamespace string + // VaultAuthMethod is the Vault authentication method to use + // (e.g. "token", "approle"). + VaultAuthMethod string + // VaultAuthNamespace is the Vault namespace used for authentication + // (Vault Enterprise only). + VaultAuthNamespace string + // VaultMountPath is the mount path of the auth method in Vault. + VaultMountPath string + // VaultRootToken is the Vault token used when VaultAuthMethod is "token". + VaultRootToken string + // VaultRoleID is the AppRole role ID used when VaultAuthMethod is "approle". + VaultRoleID string + // VaultRoleSecret is the AppRole secret ID. + VaultRoleSecret string + // VaultServerCert is the path to the CA certificate used to verify the + // Vault server's TLS certificate. + VaultServerCert string + // VaultClientCert is the path to the client TLS certificate for mutual + // TLS authentication with Vault. + VaultClientCert string + // VaultClientCertKey is the path to the private key for VaultClientCert. + VaultClientCertKey string + + // S3-backed IAM backend. Activated when S3IAMEndpoint is non-empty. + + // S3IAMAccess is the access key ID for the S3-backed IAM backend. + S3IAMAccess string + // S3IAMSecret is the secret key for the S3-backed IAM backend. + S3IAMSecret string + // S3IAMRegion is the AWS region of the S3-backed IAM bucket. + S3IAMRegion string + // S3IAMBucket is the bucket name that stores IAM account data. + S3IAMBucket string + // S3IAMEndpoint is the endpoint URL for the S3-backed IAM service. + // Useful when using a non-AWS S3-compatible store. + S3IAMEndpoint string + // S3IAMDisableSSLVerify disables TLS certificate verification for the + // S3-backed IAM connection. Use only in development or trusted internal + // environments. + S3IAMDisableSSLVerify bool + + // FreeIPA IAM backend. Activated when IpaHost is non-empty. + + // IpaHost is the hostname or URL of the FreeIPA server. + IpaHost string + // IpaVaultName is the name of the FreeIPA vault used to store credentials. + IpaVaultName string + // IpaUser is the FreeIPA username for authentication. + IpaUser string + // IpaPassword is the FreeIPA password for authentication. + IpaPassword string + // IpaInsecure disables TLS certificate verification for the FreeIPA + // connection. + IpaInsecure bool + + // IAM Cache + // + // The gateway maintains an in-memory cache of IAM account lookups to + // reduce load on the external IAM backend. The cache applies to all + // backends except single-account mode. All fields are optional. + + // IAMCacheDisable disables the in-memory IAM account cache. By default, + // accounts are cached to reduce backend lookup frequency. + IAMCacheDisable bool + // IAMCacheTTL is the time-to-live in seconds for cached IAM entries. + IAMCacheTTL int + // IAMCachePrune is the interval in seconds between cache prune runs that + // remove expired entries. + IAMCachePrune int + + // Access Logging + // + // Records details of every S3 and admin API request. All three outputs + // are independent and can be enabled simultaneously in any combination. + // All are optional; omit or leave empty to disable that output. + + // AccessLog is the file path for S3 request access logs in the AWS S3 + // access log format. Use absolute paths; relative paths may break if the + // server changes its working directory. Empty disables file logging. + AccessLog string + // LogWebhookURL is an HTTP(S) URL that receives S3 access log entries as + // JSON-encoded POST requests. Can be set alongside AccessLog. + LogWebhookURL string + // AdminLogFile is the file path for admin API request logs. + AdminLogFile string + + // Metrics + // + // The gateway can emit operational metrics to StatsD and DogStatsD. + // Both backends may be active simultaneously; set either or both. + // All fields are optional. When neither StatsdServers nor DogstatsServers + // is set, metrics are disabled. + + // MetricsService is the service name label attached to all emitted metrics. + // Defaults to the system hostname when empty. + MetricsService string + // StatsdServers is a comma-separated list of StatsD server addresses + // (e.g. "localhost:8125"). + StatsdServers string + // DogstatsServers is a comma-separated list of DogStatsD server addresses. + DogstatsServers string + + // Bucket Event Notifications + // + // The gateway can forward S3 bucket events (object created, deleted, etc.) + // to an external message broker or webhook. At most one event sink may be + // active at a time. When more than one sink's fields are populated, the + // first match in the following priority order wins: + // + // 1. EventWebhookURL -- HTTP/S webhook + // 2. KafkaURL -- Apache Kafka + // 3. NatsURL -- NATS + // 4. RabbitmqURL -- RabbitMQ + // + // Configuring event notifications is optional. When none of the trigger + // fields above are set, event notifications are disabled. + // + // EventConfigFilePath applies to whichever sink is active and can be set + // regardless of which sink is chosen. + + // KafkaURL is the broker URL for Kafka event notifications + // (e.g. "kafka://broker:9092"). + KafkaURL string + // KafkaTopic is the Kafka topic name for bucket event messages. + KafkaTopic string + // KafkaKey is the optional Kafka message key. + KafkaKey string + // NatsURL is the NATS server URL for event notifications + // (e.g. "nats://localhost:4222"). + NatsURL string + // NatsTopic is the NATS subject for bucket event messages. + NatsTopic string + // RabbitmqURL is the RabbitMQ connection URL + // (e.g. "amqp://user:pass@rabbitmq:5672/"). + RabbitmqURL string + // RabbitmqExchange is the RabbitMQ exchange to publish events to. + // Leave empty to use the default exchange. + RabbitmqExchange string + // RabbitmqRoutingKey is the routing key for RabbitMQ event messages. + // Leave empty to use no routing key. + RabbitmqRoutingKey string + // EventWebhookURL is an HTTP(S) URL that receives bucket event + // notifications as POST requests. + EventWebhookURL string + // EventConfigFilePath is the path to a JSON event filter configuration + // file that controls which events are forwarded to the active event sink. + // When empty, all events are forwarded. Generate a default config with: + // versitygw utils gen-event-filter-config --path + EventConfigFilePath string + + // WebUI + // + // The browser-based management WebUI can be served in two independent + // modes, which may be enabled simultaneously: + // + // - Standalone server (WebuiPorts): the WebUI runs on its own dedicated + // listening address(es), separate from the S3 endpoint. + // + // - Embedded on the S3 endpoint (WebuiS3Prefix): the WebUI is served + // directly from the S3 port under a URL path prefix. Useful when only + // one listening port is available. + // + // Both modes are optional. Leave WebuiPorts empty and WebuiS3Prefix empty + // to disable the WebUI entirely. + + // WebuiPorts is the list of listening addresses for the standalone WebUI + // server. Accepts the same formats as Ports. When empty, the WebUI server + // is disabled. + WebuiPorts []string + // WebuiCertFile is the path to the TLS certificate for the WebUI server. + // When empty and gateway TLS (CertFile/KeyFile) is configured, the WebUI + // inherits those certs. Both WebuiCertFile and WebuiKeyFile must be + // provided together. + WebuiCertFile string + // WebuiKeyFile is the path to the TLS private key for the WebUI server. + WebuiKeyFile string + // WebuiNoTLS forces the WebUI to use plain HTTP even when TLS certificates + // are available. Useful when TLS is terminated by a reverse proxy in front + // of the WebUI. + WebuiNoTLS bool + // WebuiGateways overrides the S3 gateway URLs provided to the WebUI. By + // default the gateway auto-detects URLs from Ports. Set this when running + // behind a reverse proxy or load balancer where the auto-detected URLs are + // incorrect (e.g. ["https://s3.example.com", "http://192.168.1.1:7070"]). + WebuiGateways []string + // WebuiAdminGateways overrides the admin gateway URLs provided to the + // WebUI. By default the gateway auto-detects URLs from AdminPorts, or + // reuses WebuiGateways when AdminPorts is empty. + WebuiAdminGateways []string + // WebuiPathPrefix is the URL path prefix under which the WebUI and its + // API endpoints are served (e.g. "/ui"). Must start with "/" and be a + // single path segment with no trailing slash. Leave empty to serve from + // the root path. + WebuiPathPrefix string + + // WebuiS3Prefix mounts the WebUI directly on the S3 API endpoint at the + // given path prefix (e.g. "/ui"). Requests matching the prefix are routed + // to the WebUI instead of S3. Any bucket whose name equals the prefix + // segment is masked. Leave empty to disable WebUI hosting on the S3 + // endpoint. + WebuiS3Prefix string + + // SigHup is an optional channel that signals the gateway to reload TLS + // certificates and rotate log files (equivalent to SIGHUP). When nil, + // this feature is disabled. + SigHup <-chan struct{} + + // Version, Build, and BuildTime are displayed in the startup banner. + // All three are optional; omit or leave empty to suppress the field. + Version string + Build string + BuildTime string +} + +// TODO: remove gatewayRunning once package-level globals (bucket-name +// validation, debug logging) are eliminated and concurrent calls are safe. +var gatewayRunning atomic.Bool + +// RunVersityGW starts the VersityGW gateway with the supplied backend and +// configuration. It blocks until ctx is cancelled, or an error occurs. All +// subsystems are gracefully shut down before the function returns. +// +// Only one instance may run per process at a time. Calling RunVersityGW +// concurrently or a second time before the first call returns will return an +// error. +func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { + if !gatewayRunning.CompareAndSwap(false, true) { + return fmt.Errorf("embedgw: RunVersityGW is already running; only one instance per process is supported") + } + defer gatewayRunning.Store(false) + + if cfg.RootUserAccess == "" || cfg.RootUserSecret == "" { + return fmt.Errorf("root user access and secret key must be provided") + } + + err := validateWebUIPathPrefix("WebuiPathPrefix", cfg.WebuiPathPrefix) + if err != nil { + return err + } + + if cfg.MaxConnections < 1 { + return fmt.Errorf("max-connections must be positive") + } + if cfg.MaxRequests < 1 { + return fmt.Errorf("max-requests must be positive") + } + if cfg.MaxRequests > cfg.MaxConnections { + log.Printf("WARNING: max-requests (%d) exceeds max-connections (%d) which could allow for gateway to panic before throttling requests", + cfg.MaxRequests, cfg.MaxConnections) + } + if cfg.MultipartMaxParts < 1 { + return fmt.Errorf("mp-max-parts must be positive") + } + + if len(cfg.Ports) == 0 { + return fmt.Errorf("no ports specified") + } + + if cfg.Region == "" { + cfg.Region = awsDefaultRegion + } + + // 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. + corsAllowOrigin := cfg.CORSAllowOrigin + if len(cfg.WebuiPorts) > 0 && strings.TrimSpace(corsAllowOrigin) == "" { + corsAllowOrigin = "*" + webuiScheme := "http" + if !cfg.WebuiNoTLS && (strings.TrimSpace(cfg.WebuiCertFile) != "" || strings.TrimSpace(cfg.CertFile) != "") { + webuiScheme = "https" + } + + var suggestion string + var allOrigins []string + for _, addr := range cfg.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)) + } + } + } + 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) + } + + fmt.Fprintf(os.Stderr, "WARNING: WebuiPorts is set but CORSAllowOrigin is not; defaulting to '*'; %s\n", suggestion) + } + + if err := validatePortConflicts(cfg.Ports, cfg.AdminPorts, cfg.WebuiPorts); err != nil { + return err + } + + if err := validateWebUIPathPrefix("WebuiS3Prefix", cfg.WebuiS3Prefix); err != nil { + return err + } + + // Pre-validate gateway URL lists once; both the WebuiS3Prefix block and the + // WebuiPorts block need these, so validate here to avoid doing it twice. + var validatedWebuiGateways []string + if len(cfg.WebuiGateways) > 0 { + validatedWebuiGateways, err = validateGatewayURLs(cfg.WebuiGateways, "WebuiGateways") + if err != nil { + return err + } + } + var validatedWebuiAdminGateways []string + if len(cfg.WebuiAdminGateways) > 0 { + validatedWebuiAdminGateways, err = validateGatewayURLs(cfg.WebuiAdminGateways, "WebuiAdminGateways") + if err != nil { + return err + } + } + + utils.SetBucketNameValidationStrict(!cfg.DisableStrictBucketNames) + + var parsedSocketPerm os.FileMode + if cfg.SocketPerm != "" { + perm, err := strconv.ParseUint(cfg.SocketPerm, 8, 32) + if err != nil { + return fmt.Errorf("invalid SocketPerm value %q: must be an octal integer (e.g. '0660'): %w", cfg.SocketPerm, err) + } + parsedSocketPerm = os.FileMode(perm) + } + + opts := []s3api.Option{ + s3api.WithConcurrencyLimiter(cfg.MaxConnections, cfg.MaxRequests), + s3api.WithMpMaxParts(cfg.MultipartMaxParts), + } + if cfg.SocketPerm != "" { + opts = append(opts, s3api.WithSocketPerm(parsedSocketPerm)) + } + if corsAllowOrigin != "" { + opts = append(opts, s3api.WithCORSAllowOrigin(corsAllowOrigin)) + } + + if cfg.CertFile != "" || cfg.KeyFile != "" { + if cfg.CertFile == "" { + return fmt.Errorf("TLS key specified without cert file") + } + if cfg.KeyFile == "" { + return fmt.Errorf("TLS cert specified without key file") + } + cs := utils.NewCertStorage() + if err := cs.SetCertificate(cfg.CertFile, cfg.KeyFile); err != nil { + return fmt.Errorf("tls: load certs: %v", err) + } + opts = append(opts, s3api.WithTLS(cs)) + } + if len(cfg.AdminPorts) == 0 { + opts = append(opts, s3api.WithAdminServer()) + } + if cfg.Quiet { + opts = append(opts, s3api.WithQuiet()) + } + if cfg.HealthPath != "" { + opts = append(opts, s3api.WithHealth(cfg.HealthPath)) + } + if cfg.Readonly { + opts = append(opts, s3api.WithReadOnly()) + } + if cfg.VirtualDomain != "" { + opts = append(opts, s3api.WithHostStyle(cfg.VirtualDomain)) + } + if cfg.KeepAlive { + opts = append(opts, s3api.WithKeepAlive()) + } + if cfg.DisableACLs { + opts = append(opts, s3api.WithDisableACL()) + } + if cfg.Debug { + debuglogger.SetDebugEnabled() + } + if cfg.IAMDebug { + debuglogger.SetIAMDebugEnabled() + } + + iam, err := auth.New(&auth.Opts{ + RootAccount: auth.Account{ + Access: cfg.RootUserAccess, + Secret: cfg.RootUserSecret, + Role: auth.RoleAdmin, + }, + Dir: cfg.IAMDir, + LDAPServerURL: cfg.LDAPServerURL, + LDAPBindDN: cfg.LDAPBindDN, + LDAPPassword: cfg.LDAPPassword, + LDAPQueryBase: cfg.LDAPQueryBase, + LDAPObjClasses: cfg.LDAPObjClasses, + LDAPAccessAtr: cfg.LDAPAccessAttr, + LDAPSecretAtr: cfg.LDAPSecretAttr, + LDAPRoleAtr: cfg.LDAPRoleAttr, + LDAPUserIdAtr: cfg.LDAPUserIDAttr, + LDAPGroupIdAtr: cfg.LDAPGroupIDAttr, + LDAPProjectIdAtr: cfg.LDAPProjectIDAttr, + LDAPTLSSkipVerify: cfg.LDAPTLSSkipVerify, + VaultEndpointURL: cfg.VaultEndpointURL, + VaultNamespace: cfg.VaultNamespace, + VaultSecretStoragePath: cfg.VaultSecretStoragePath, + VaultSecretStorageNamespace: cfg.VaultSecretStorageNamespace, + VaultAuthMethod: cfg.VaultAuthMethod, + VaultAuthNamespace: cfg.VaultAuthNamespace, + VaultMountPath: cfg.VaultMountPath, + VaultRootToken: cfg.VaultRootToken, + VaultRoleId: cfg.VaultRoleID, + VaultRoleSecret: cfg.VaultRoleSecret, + VaultServerCert: cfg.VaultServerCert, + VaultClientCert: cfg.VaultClientCert, + VaultClientCertKey: cfg.VaultClientCertKey, + S3Access: cfg.S3IAMAccess, + S3Secret: cfg.S3IAMSecret, + S3Region: cfg.S3IAMRegion, + S3Bucket: cfg.S3IAMBucket, + S3Endpoint: cfg.S3IAMEndpoint, + S3DisableSSlVerfiy: cfg.S3IAMDisableSSLVerify, + CacheDisable: cfg.IAMCacheDisable, + CacheTTL: cfg.IAMCacheTTL, + CachePrune: cfg.IAMCachePrune, + IpaHost: cfg.IpaHost, + IpaVaultName: cfg.IpaVaultName, + IpaUser: cfg.IpaUser, + IpaPassword: cfg.IpaPassword, + IpaInsecure: cfg.IpaInsecure, + }) + if err != nil { + return fmt.Errorf("setup iam: %w", err) + } + + loggers, err := s3log.InitLogger(&s3log.LogConfig{ + LogFile: cfg.AccessLog, + WebhookURL: cfg.LogWebhookURL, + AdminLogFile: cfg.AdminLogFile, + }) + if err != nil { + return fmt.Errorf("setup logger: %w", err) + } + + metricsManager, err := metrics.NewManager(ctx, metrics.Config{ + ServiceName: cfg.MetricsService, + StatsdServers: cfg.StatsdServers, + DogStatsdServers: cfg.DogstatsServers, + }) + if err != nil { + return fmt.Errorf("init metrics manager: %w", err) + } + + evSender, err := s3event.InitEventSender(&s3event.EventConfig{ + KafkaURL: cfg.KafkaURL, + KafkaTopic: cfg.KafkaTopic, + KafkaTopicKey: cfg.KafkaKey, + NatsURL: cfg.NatsURL, + NatsTopic: cfg.NatsTopic, + RabbitmqURL: cfg.RabbitmqURL, + RabbitmqExchange: cfg.RabbitmqExchange, + RabbitmqRoutingKey: cfg.RabbitmqRoutingKey, + WebhookURL: cfg.EventWebhookURL, + FilterConfigFilePath: cfg.EventConfigFilePath, + }) + if err != nil { + return fmt.Errorf("init bucket event notifications: %w", err) + } + + if cfg.WebuiS3Prefix != "" { + s3SSLEnabled := cfg.CertFile != "" + s3AdmSSLEnabled := s3SSLEnabled + if len(cfg.AdminPorts) > 0 { + s3AdmSSLEnabled = cfg.AdminCertFile != "" + } + + var s3WebGateways []string + if len(validatedWebuiGateways) > 0 { + s3WebGateways = validatedWebuiGateways + } else { + for _, p := range cfg.Ports { + urls, err := buildServiceURLs(p, s3SSLEnabled) + if err != nil { + return fmt.Errorf("webui-s3-prefix: build gateway URLs: %w", err) + } + s3WebGateways = append(s3WebGateways, urls...) + } + sortGatewayURLs(s3WebGateways) + } + + s3WebAdminGateways := s3WebGateways + if len(validatedWebuiAdminGateways) > 0 { + s3WebAdminGateways = validatedWebuiAdminGateways + } else if len(cfg.AdminPorts) > 0 { + s3WebAdminGateways = nil + for _, admPort := range cfg.AdminPorts { + urls, err := buildServiceURLs(admPort, s3AdmSSLEnabled) + if err != nil { + return fmt.Errorf("webui-s3-prefix: build admin gateway URLs: %w", err) + } + s3WebAdminGateways = append(s3WebAdminGateways, urls...) + } + sortGatewayURLs(s3WebAdminGateways) + } + + opts = append(opts, s3api.WithWebUI(cfg.WebuiS3Prefix, &webui.ServerConfig{ + Gateways: s3WebGateways, + AdminGateways: s3WebAdminGateways, + Region: cfg.Region, + })) + } + + srv, err := s3api.New(be, middlewares.RootUserConfig{ + Access: cfg.RootUserAccess, + Secret: cfg.RootUserSecret, + }, cfg.Region, iam, loggers.S3Logger, loggers.AdminLogger, evSender, metricsManager, opts...) + if err != nil { + return fmt.Errorf("init gateway: %v", err) + } + + var admSrv *s3api.S3AdminServer + + if len(cfg.AdminPorts) > 0 { + if cfg.AdminMaxConnections < 1 { + return fmt.Errorf("admin-max-connections must be positive") + } + if cfg.AdminMaxRequests < 1 { + return fmt.Errorf("admin-max-requests must be positive") + } + if cfg.AdminMaxRequests > cfg.AdminMaxConnections { + log.Printf("WARNING: admin-max-requests (%d) exceeds admin-max-connections (%d) which could allow for gateway to panic before throttling requests", + cfg.AdminMaxRequests, cfg.AdminMaxConnections) + } + + admOpts := []s3api.AdminOpt{ + s3api.WithAdminConcurrencyLimiter(cfg.AdminMaxConnections, cfg.AdminMaxRequests), + } + + if corsAllowOrigin != "" { + admOpts = append(admOpts, s3api.WithAdminCORSAllowOrigin(corsAllowOrigin)) + } + + if cfg.AdminCertFile != "" || cfg.AdminKeyFile != "" { + if cfg.AdminCertFile == "" { + return fmt.Errorf("TLS key specified without cert file") + } + if cfg.AdminKeyFile == "" { + return fmt.Errorf("TLS cert specified without key file") + } + cs := utils.NewCertStorage() + if err = cs.SetCertificate(cfg.AdminCertFile, cfg.AdminKeyFile); err != nil { + return fmt.Errorf("tls: load certs: %v", err) + } + admOpts = append(admOpts, s3api.WithAdminSrvTLS(cs)) + } + if cfg.Quiet { + admOpts = append(admOpts, s3api.WithAdminQuiet()) + } + if cfg.Debug { + admOpts = append(admOpts, s3api.WithAdminDebug()) + } + if cfg.SocketPerm != "" { + admOpts = append(admOpts, s3api.WithAdminSocketPerm(parsedSocketPerm)) + } + + admSrv = s3api.NewAdminServer(be, middlewares.RootUserConfig{Access: cfg.RootUserAccess, Secret: cfg.RootUserSecret}, cfg.Region, iam, loggers.AdminLogger, srv.Router.Ctrl, admOpts...) + } + + var webSrv *webui.Server + webTLSCert := "" + webTLSKey := "" + if len(cfg.WebuiPorts) > 0 { + for _, addr := range cfg.WebuiPorts { + if utils.IsUnixSocketPath(addr) { + continue + } + _, 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 + if !cfg.WebuiNoTLS { + webTLSCert = cfg.WebuiCertFile + webTLSKey = cfg.WebuiKeyFile + if webTLSCert == "" && webTLSKey == "" { + webTLSCert = cfg.CertFile + webTLSKey = cfg.KeyFile + } + if webTLSCert != "" || webTLSKey != "" { + if webTLSCert == "" { + return fmt.Errorf("webui TLS key specified without cert file") + } + if webTLSKey == "" { + return fmt.Errorf("webui TLS cert specified without key file") + } + cs := utils.NewCertStorage() + if err := cs.SetCertificate(webTLSCert, webTLSKey); err != nil { + return fmt.Errorf("tls: load certs: %v", err) + } + webOpts = append(webOpts, webui.WithTLS(cs)) + } + } + + sslEnabled := cfg.CertFile != "" + admSSLEnabled := sslEnabled + if len(cfg.AdminPorts) > 0 { + admSSLEnabled = cfg.AdminCertFile != "" + } + + var gateways []string + if len(validatedWebuiGateways) > 0 { + gateways = validatedWebuiGateways + } else { + for _, p := range cfg.Ports { + urls, err := buildServiceURLs(p, sslEnabled) + if err != nil { + return fmt.Errorf("webui: build gateway URLs: %w", err) + } + gateways = append(gateways, urls...) + } + sortGatewayURLs(gateways) + } + + adminGateways := gateways + if len(validatedWebuiAdminGateways) > 0 { + adminGateways = validatedWebuiAdminGateways + } else if len(cfg.AdminPorts) > 0 { + adminGateways = nil + for _, admPort := range cfg.AdminPorts { + urls, err := buildServiceURLs(admPort, admSSLEnabled) + if err != nil { + return fmt.Errorf("webui: build admin gateway URLs: %w", err) + } + adminGateways = append(adminGateways, urls...) + } + sortGatewayURLs(adminGateways) + } + + if cfg.Quiet { + webOpts = append(webOpts, webui.WithQuiet()) + } + if cfg.WebuiPathPrefix != "" { + webOpts = append(webOpts, webui.WithPathPrefix(cfg.WebuiPathPrefix)) + } + if cfg.SocketPerm != "" { + webOpts = append(webOpts, webui.WithSocketPerm(parsedSocketPerm)) + } + + webSrv = webui.NewServer(&webui.ServerConfig{ + Gateways: gateways, + AdminGateways: adminGateways, + Region: cfg.Region, + }, webOpts...) + } + + if !cfg.Quiet { + cfg.printBanner() + } + + servers := 1 + if len(cfg.AdminPorts) > 0 { + servers++ + } + if len(cfg.WebuiPorts) > 0 { + servers++ + } + + c := make(chan error, servers) + go func() { c <- srv.ServeMultiPort(cfg.Ports) }() + if len(cfg.AdminPorts) > 0 { + go func() { c <- admSrv.ServeMultiPort(cfg.AdminPorts) }() + } + if len(cfg.WebuiPorts) > 0 { + go func() { c <- webSrv.ServeMultiPort(cfg.WebuiPorts) }() + } + + // build a nil-safe sighup channel so the select below is always valid + var sigHup <-chan struct{} + if cfg.SigHup != nil { + sigHup = cfg.SigHup + } else { + sigHup = make(chan struct{}) // never receives + } + +Loop: + for { + select { + case <-ctx.Done(): + break Loop + case err = <-c: + break Loop + case <-sigHup: + if loggers.S3Logger != nil { + err = loggers.S3Logger.HangUp() + if err != nil { + err = fmt.Errorf("HUP s3 logger: %w", err) + break Loop + } + } + if loggers.AdminLogger != nil { + err = loggers.AdminLogger.HangUp() + if err != nil { + err = fmt.Errorf("HUP admin logger: %w", err) + break Loop + } + } + if cfg.CertFile != "" && cfg.KeyFile != "" { + reloadErr := srv.CertStorage.SetCertificate(cfg.CertFile, cfg.KeyFile) + if reloadErr != nil { + debuglogger.InternalError(fmt.Errorf("srv cert reload failed: %w", reloadErr)) + } else { + fmt.Printf("srv cert reloaded (cert: %s, key: %s)\n", cfg.CertFile, cfg.KeyFile) + } + } + if len(cfg.AdminPorts) > 0 && cfg.AdminCertFile != "" && cfg.AdminKeyFile != "" { + reloadErr := admSrv.CertStorage.SetCertificate(cfg.AdminCertFile, cfg.AdminKeyFile) + if reloadErr != nil { + debuglogger.InternalError(fmt.Errorf("admSrv cert reload failed: %w", reloadErr)) + } else { + fmt.Printf("admSrv cert reloaded (cert: %s, key: %s)\n", cfg.AdminCertFile, cfg.AdminKeyFile) + } + } + if len(cfg.WebuiPorts) > 0 && webTLSCert != "" && webTLSKey != "" { + reloadErr := webSrv.CertStorage.SetCertificate(webTLSCert, webTLSKey) + if reloadErr != nil { + debuglogger.InternalError(fmt.Errorf("webSrv cert reload failed: %w", reloadErr)) + } else { + fmt.Printf("webSrv cert reloaded (cert: %s, key: %s)\n", webTLSCert, webTLSKey) + } + } + } + } + saveErr := err + + err = srv.ShutDown() + if err != nil { + fmt.Fprintf(os.Stderr, "shutdown api server: %v\n", err) + } + + if admSrv != nil { + err := admSrv.Shutdown() + if err != nil { + fmt.Fprintf(os.Stderr, "shutdown admin server: %v\n", err) + } + } + + if webSrv != nil { + err := webSrv.Shutdown() + if err != nil { + fmt.Fprintf(os.Stderr, "shutdown webui server: %v\n", err) + } + } + + be.Shutdown() + + err = iam.Shutdown() + if err != nil { + fmt.Fprintf(os.Stderr, "shutdown iam: %v\n", err) + } + + if loggers.S3Logger != nil { + err := loggers.S3Logger.Shutdown() + if err != nil { + fmt.Fprintf(os.Stderr, "shutdown s3 logger: %v\n", err) + } + } + if loggers.AdminLogger != nil { + err := loggers.AdminLogger.Shutdown() + if err != nil { + fmt.Fprintf(os.Stderr, "shutdown admin logger: %v\n", err) + } + } + + if evSender != nil { + err := evSender.Close() + if err != nil { + fmt.Fprintf(os.Stderr, "close event sender: %v\n", err) + } + } + + if metricsManager != nil { + metricsManager.Close() + } + + return saveErr +} + +const ( + columnWidth = 70 + title = "VersityGW" +) + +func (cfg Config) printBanner() { + ssl := cfg.CertFile != "" || cfg.KeyFile != "" + admSSL := cfg.AdminCertFile != "" || cfg.AdminKeyFile != "" + webuiSsl := !cfg.WebuiNoTLS && (cfg.WebuiCertFile != "" || cfg.WebuiKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "") + + if len(cfg.Ports) == 0 { + fmt.Fprintf(os.Stderr, "No ports specified\n") + return + } + + var allInterfaces []string + var allPorts []string + interfaceMap := make(map[string]bool) + + for _, portSpec := range cfg.Ports { + if utils.IsUnixSocketPath(portSpec) { + allPorts = append(allPorts, portSpec) + if !interfaceMap[portSpec] { + interfaceMap[portSpec] = true + allInterfaces = append(allInterfaces, portSpec) + } + continue + } + interfaces, err := getMatchingIPs(portSpec) + if err != nil { + 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 + } + + var allAdmInterfaces []string + admInterfaceMap := make(map[string]bool) + for _, admPort := range cfg.AdminPorts { + if utils.IsUnixSocketPath(admPort) { + if !admInterfaceMap[admPort] { + admInterfaceMap[admPort] = true + allAdmInterfaces = append(allAdmInterfaces, admPort) + } + continue + } + 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) + } + } + } + + versionStr := fmt.Sprintf("Version %v, Build %v", cfg.Version, cfg.Build) + if cfg.BuildTime != "" { + versionStr += fmt.Sprintf(", BuildTime %v", cfg.BuildTime) + } + var urls []string + + for _, addrPort := range allInterfaces { + if utils.IsUnixSocketPath(addrPort) { + urls = append(urls, "unix:"+addrPort) + continue + } + ip, prt, err := net.SplitHostPort(addrPort) + if err != nil { + continue + } + hostPort := net.JoinHostPort(ip, prt) + u := fmt.Sprintf("http://%s", hostPort) + if ssl { + u = fmt.Sprintf("https://%s", hostPort) + } + urls = append(urls, u) + } + + var boundHost string + if len(cfg.Ports) == 1 { + if utils.IsUnixSocketPath(cfg.Ports[0]) { + boundHost = fmt.Sprintf("(unix socket: %s)", cfg.Ports[0]) + } else { + hst, prt, _ := net.SplitHostPort(cfg.Ports[0]) + if hst == "" { + hst = "0.0.0.0" + } + boundHost = fmt.Sprintf("(bound on host %s and port %s)", hst, prt) + } + } else { + portList := strings.Join(allPorts, ", ") + boundHost = fmt.Sprintf("(bound on ports: %s)", portList) + } + + lines := []string{ + centerText(title), + centerText(versionStr), + centerText(boundHost), + centerText(""), + } + + if len(allAdmInterfaces) > 0 { + lines = append(lines, leftText("S3 service listening on:")) + } else { + lines = append(lines, leftText("Admin/S3 service listening on:")) + } + + for _, u := range urls { + lines = append(lines, leftText(" "+u)) + } + + if len(allAdmInterfaces) > 0 { + lines = append(lines, centerText(""), leftText("Admin service listening on:")) + for _, addrPort := range allAdmInterfaces { + if utils.IsUnixSocketPath(addrPort) { + lines = append(lines, leftText(" unix:"+addrPort)) + continue + } + ip, prt, err := net.SplitHostPort(addrPort) + if err != nil { + continue + } + hostPort := net.JoinHostPort(ip, prt) + u := fmt.Sprintf("http://%s", hostPort) + if admSSL { + u = fmt.Sprintf("https://%s", hostPort) + } + lines = append(lines, leftText(" "+u)) + } + } + + if len(cfg.WebuiPorts) > 0 { + var allWebInterfaces []string + webInterfaceMap := make(map[string]bool) + + for _, webuiAddr := range cfg.WebuiPorts { + if strings.TrimSpace(webuiAddr) == "" { + continue + } + if utils.IsUnixSocketPath(webuiAddr) { + if !webInterfaceMap[webuiAddr] { + webInterfaceMap[webuiAddr] = true + allWebInterfaces = append(allWebInterfaces, 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) + } + } + } + + if len(allWebInterfaces) > 0 { + lines = append(lines, centerText(""), leftText("WebUI listening on:")) + for _, addrPort := range allWebInterfaces { + if utils.IsUnixSocketPath(addrPort) { + lines = append(lines, leftText(" unix:"+addrPort)) + continue + } + ip, prt, err := net.SplitHostPort(addrPort) + if err != nil { + continue + } + hostPort := net.JoinHostPort(ip, prt) + u := fmt.Sprintf("http://%s", hostPort) + if webuiSsl { + u = fmt.Sprintf("https://%s", hostPort) + } + lines = append(lines, leftText(" "+u+cfg.WebuiPathPrefix)) + } + } + } + + if cfg.WebuiS3Prefix != "" { + lines = append(lines, centerText(""), leftText("WebUI embedded on S3 service at:")) + for _, addrPort := range allInterfaces { + ip, prt, err := net.SplitHostPort(addrPort) + if err != nil { + continue + } + hostPort := net.JoinHostPort(ip, prt) + u := fmt.Sprintf("http://%s", hostPort) + if ssl { + u = fmt.Sprintf("https://%s", hostPort) + } + lines = append(lines, leftText(" "+u+cfg.WebuiS3Prefix)) + } + } + + fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐") + for _, line := range lines { + fmt.Printf("│%-*s│\n", columnWidth-2, line) + } + fmt.Println("└" + strings.Repeat("─", columnWidth-2) + "┘") +} + +func centerText(text string) string { + padding := max((columnWidth-2-len(text))/2, 0) + return strings.Repeat(" ", padding) + text +} + +func leftText(text string) string { + if len(text) > columnWidth-2 { + return text + } + return text + strings.Repeat(" ", columnWidth-2-len(text)) +} + +// getMatchingIPs returns all IP addresses that the server will listen on +// for the given address specification. +func getMatchingIPs(spec string) ([]string, error) { + if utils.IsUnixSocketPath(spec) { + return []string{spec}, nil + } + + ips, err := utils.ResolveHostnameIPs(spec) + if err != nil { + return nil, fmt.Errorf("resolve hostname: %v", err) + } + + if len(ips) == 1 && ips[0] == "" { + return getAllLocalIPs() + } + + 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) + } + + return result, nil +} + +// getAllLocalIPs returns all non-link-local IP addresses from local interfaces. +func getAllLocalIPs() ([]string, error) { + var result []string + + interfaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + for _, iface := range interfaces { + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ipAddr, _, err := net.ParseCIDR(addr.String()) + if err != nil { + continue + } + if ipAddr.IsLinkLocalUnicast() || ipAddr.IsInterfaceLocalMulticast() || ipAddr.IsLinkLocalMulticast() { + continue + } + result = append(result, ipAddr.String()) + } + } + + return result, nil +} + +func buildServiceURLs(spec string, ssl bool) ([]string, error) { + if utils.IsUnixSocketPath(spec) { + return nil, nil + } + + interfaces, err := getMatchingIPs(spec) + if err != nil { + return nil, err + } + _, prt, err := net.SplitHostPort(spec) + if err != nil { + return nil, fmt.Errorf("parse address/port: %w", err) + } + if len(interfaces) == 0 { + interfaces = []string{"localhost"} + } + + scheme := "http" + if ssl { + scheme = "https" + } + urls := make([]string, 0, len(interfaces)) + for _, ip := range interfaces { + urls = append(urls, fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(ip, prt))) + } + return urls, nil +} + +func isLocalhost(u string) bool { + return strings.Contains(u, "localhost") || + strings.Contains(u, "127.0.0.1") || + strings.Contains(u, "[::1]") +} + +func validateGatewayURLs(urls []string, urlType string) ([]string, error) { + if len(urls) == 0 { + return urls, nil + } + + var validURLs []string + for _, urlStr := range urls { + if strings.TrimSpace(urlStr) == "" { + continue + } + parsedURL, err := url.Parse(urlStr) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: %v\n", urlType, urlStr, err) + continue + } + if parsedURL.Scheme == "" { + fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: missing scheme (must be http:// or https://)\n", urlType, urlStr) + continue + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: unsupported scheme %q (must be http or https)\n", urlType, urlStr, parsedURL.Scheme) + continue + } + if parsedURL.Host == "" { + fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: missing host\n", urlType, urlStr) + continue + } + validURLs = append(validURLs, urlStr) + } + + if len(validURLs) == 0 { + return nil, fmt.Errorf("%s URLs specified but none are valid", urlType) + } + + return validURLs, nil +} + +func validateWebUIPathPrefix(option, prefix string) error { + if prefix == "" { + return nil + } + if strings.TrimSpace(prefix) != prefix { + return fmt.Errorf("invalid %v %q: must not contain leading or trailing whitespace", option, prefix) + } + if !strings.HasPrefix(prefix, "/") { + return fmt.Errorf("invalid %v %q: must start with '/' (example: '/ui')", option, prefix) + } + if strings.HasSuffix(prefix, "/") { + return fmt.Errorf("invalid %v %q: must not end with '/'", option, prefix) + } + if strings.Count(prefix, "/") > 1 { + return fmt.Errorf("invalid %v %q: only a single path segment is allowed (example: '/ui')", option, prefix) + } + if strings.ContainsAny(prefix, "?#") { + return fmt.Errorf("invalid %v %q: query strings and fragments are not allowed", option, prefix) + } + if strings.Contains(prefix, "\\") { + return fmt.Errorf("invalid %v %q: backslashes are not allowed", option, prefix) + } + return nil +} + +func sortGatewayURLs(urls []string) { + if len(urls) <= 1 { + return + } + var nonLocal []string + var local []string + for _, u := range urls { + if isLocalhost(u) { + local = append(local, u) + } else { + nonLocal = append(nonLocal, u) + } + } + copy(urls, nonLocal) + copy(urls[len(nonLocal):], local) +} + +// validatePortConflicts checks for port conflicts across the S3 API, admin, +// and WebUI port lists before the servers are started. +// +// A bare port spec (e.g. ":7071") binds to all interfaces and conflicts with +// any other spec on the same port number. Two identical "ip:port" specs are +// allowed and will be caught by the OS later. UNIX socket paths are checked +// for duplicate path conflicts only and never conflict with TCP specs. +func validatePortConflicts(ports, admPorts, webuiPorts []string) error { + type portSpec struct { + spec string + port string + isBare bool + isUnix bool + portType string + } + + var allSpecs []portSpec + + for _, p := range ports { + if utils.IsUnixSocketPath(p) { + allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "s3"}) + continue + } + _, port, err := net.SplitHostPort(p) + if err != nil { + continue + } + allSpecs = append(allSpecs, portSpec{ + spec: p, + port: port, + isBare: strings.HasPrefix(p, ":"), + portType: "s3", + }) + } + + for _, p := range admPorts { + if utils.IsUnixSocketPath(p) { + allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "admin"}) + continue + } + _, port, err := net.SplitHostPort(p) + if err != nil { + continue + } + allSpecs = append(allSpecs, portSpec{ + spec: p, + port: port, + isBare: strings.HasPrefix(p, ":"), + portType: "admin", + }) + } + + for _, p := range webuiPorts { + if utils.IsUnixSocketPath(p) { + allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "webui"}) + continue + } + _, port, err := net.SplitHostPort(p) + if err != nil { + continue + } + allSpecs = append(allSpecs, portSpec{ + spec: p, + port: port, + isBare: strings.HasPrefix(p, ":"), + portType: "webui", + }) + } + + for i, spec1 := range allSpecs { + for j, spec2 := range allSpecs { + if i >= j { + continue + } + if spec1.isUnix || spec2.isUnix { + if spec1.isUnix && spec2.isUnix && spec1.spec == spec2.spec { + return fmt.Errorf("duplicate unix socket path: %s port %s conflicts with %s port %s", + spec1.portType, spec1.spec, spec2.portType, spec2.spec) + } + continue + } + if spec1.port != spec2.port { + continue + } + if !spec1.isBare && !spec2.isBare && spec1.spec == spec2.spec { + continue + } + if spec1.isBare || spec2.isBare { + return fmt.Errorf("port conflict: %s port %s conflicts with %s port %s (bare port specs bind to all interfaces)", + spec1.portType, spec1.spec, spec2.portType, spec2.spec) + } + } + } + + return nil +} diff --git a/embedgw/embedgw_test.go b/embedgw/embedgw_test.go new file mode 100644 index 00000000..781b14a7 --- /dev/null +++ b/embedgw/embedgw_test.go @@ -0,0 +1,131 @@ +// 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 embedgw + +import ( + "testing" +) + +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) + } + }) + } +}