diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index 288b1ed..3e20a80 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -156,6 +156,19 @@ documentation can be found in the GitHub wiki.`, admPorts = ctx.StringSlice("admin-port") webuiGateways = ctx.StringSlice("webui-gateways") webuiAdminGateways = ctx.StringSlice("webui-admin-gateways") + + // Resolve relative UNIX socket paths to absolute before any backend + // (e.g. posix) can change the working directory via os.Chdir. + var err error + if ports, err = utils.AbsSocketPaths(ports); err != nil { + return err + } + if admPorts, err = utils.AbsSocketPaths(admPorts); err != nil { + return err + } + if webuiPorts, err = utils.AbsSocketPaths(webuiPorts); err != nil { + return err + } return nil }, Action: func(ctx *cli.Context) error { @@ -181,14 +194,14 @@ func initFlags() []cli.Flag { }, &cli.StringSliceFlag{ Name: "port", - Usage: "gateway listen address : or : (can be specified multiple times for listening on multiple addresses)", + Usage: "gateway listen address: :, :, /path/to/socket for file-backed UNIX sockets, or @name for Linux abstract namespace sockets (can be specified multiple times for listening on multiple addresses)", EnvVars: []string{"VGW_PORT"}, Value: cli.NewStringSlice(":7070"), Aliases: []string{"p"}, }, &cli.StringSliceFlag{ Name: "webui", - Usage: "enable WebUI server on the specified listen address (e.g. ':7071', '127.0.0.1:7071', 'localhost:7071'; can be specified multiple times for listening on multiple addresses; disabled when omitted)", + Usage: "enable WebUI server on the specified listen address (e.g. ':7071', '127.0.0.1:7071', 'localhost:7071', '/run/vgw/webui.sock'; supports the same UNIX socket forms as --port; can be specified multiple times for listening on multiple addresses; disabled when omitted)", EnvVars: []string{"VGW_WEBUI_PORT"}, }, &cli.StringFlag{ @@ -277,7 +290,7 @@ func initFlags() []cli.Flag { }, &cli.StringSliceFlag{ Name: "admin-port", - Usage: "gateway admin server listen address : or : (can be specified multiple times for listening on multiple addresses)", + Usage: "gateway admin server listen address: :, :, /path/to/socket for file-backed UNIX sockets, or @name for Linux abstract namespace sockets (can be specified multiple times for listening on multiple addresses)", EnvVars: []string{"VGW_ADMIN_PORT"}, Aliases: []string{"ap"}, }, @@ -1242,6 +1255,14 @@ func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs 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) @@ -1271,6 +1292,13 @@ func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs 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) @@ -1296,6 +1324,10 @@ func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs // 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 @@ -1313,11 +1345,15 @@ func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs // Determine bound host description var boundHost string if len(ports) == 1 { - hst, prt, _ := net.SplitHostPort(ports[0]) - if hst == "" { - hst = "0.0.0.0" + 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) } - boundHost = fmt.Sprintf("(bound on host %s and port %s)", hst, prt) } else { // Multiple ports portList := strings.Join(allPorts, ", ") @@ -1352,6 +1388,10 @@ func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs ) 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 @@ -1374,6 +1414,13 @@ func printBanner(ports []string, admPorts []string, ssl, admSsl bool, 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) @@ -1399,6 +1446,10 @@ func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs 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 @@ -1429,6 +1480,11 @@ func printBanner(ports []string, admPorts []string, ssl, admSsl bool, webuiAddrs // 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) @@ -1488,6 +1544,12 @@ func getAllLocalIPs() ([]string, error) { } 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 @@ -1593,6 +1655,8 @@ func sortGatewayURLs(urls []string) { // 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 { @@ -1600,6 +1664,7 @@ func validatePortConflicts(ports, admPorts, webuiPorts []string) error { spec string port string isBare bool + isUnix bool portType string // "s3", "admin", or "webui" } @@ -1607,6 +1672,10 @@ func validatePortConflicts(ports, admPorts, webuiPorts []string) error { // 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 @@ -1620,6 +1689,10 @@ func validatePortConflicts(ports, admPorts, webuiPorts []string) error { } 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 @@ -1633,6 +1706,10 @@ func validatePortConflicts(ports, admPorts, webuiPorts []string) error { } 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 @@ -1652,6 +1729,16 @@ func validatePortConflicts(ports, admPorts, webuiPorts []string) error { 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 diff --git a/extra/example.conf b/extra/example.conf index 8be8bd7..a64ec8e 100644 --- a/extra/example.conf +++ b/extra/example.conf @@ -69,6 +69,11 @@ ROOT_SECRET_ACCESS_KEY= # in /etc/services. # To specify multiple ports, use a comma-separated list # (e.g., VGW_PORT=:7070,:8080,localhost:9090). +# UNIX domain sockets are also supported by specifying a path or on Linux +# an abstract namespace socket with the "@" prefix (e.g., @versitygw-s3). +# UNIX socket addresses can be mixed with +# TCP/IP addresses in a comma-separated list +# (e.g., VGW_PORT=:7070,/run/vgw/s3.sock). #VGW_PORT=:7070 # The VGW_REGION option will specify the region that the S3 server will @@ -89,8 +94,10 @@ ROOT_SECRET_ACCESS_KEY= # these are not specified is to have the admin server listen on the same # endpoint as the S3 service. This can specify multiple ports with comma # separated list and will resolve hostnames to multiple addresses the same -# as VGW_PORT. When VGW_ADMIN_CERT and VGW_ADMIN_CERT_KEY are specified, -# the admin server will use SSL. +# as VGW_PORT. UNIX domain sockets are supported using the same syntax as +# VGW_PORT (absolute/relative paths and Linux abstract "@" sockets). +# When VGW_ADMIN_CERT and VGW_ADMIN_CERT_KEY are specified, the admin server +# will use SSL. #VGW_ADMIN_PORT= #VGW_ADMIN_CERT= #VGW_ADMIN_CERT_KEY= @@ -225,7 +232,9 @@ ROOT_SECRET_ACCESS_KEY= # interfaces (e.g., ':7071') or 'host:port' to listen on a specific interface # (e.g., '127.0.0.1:7071' or 'localhost:7071'). When omitted, the Web GUI is # disabled. This can specify multiple ports with comma separated list and will -# resolve hostnames to multiple addresses the same as VGW_PORT. +# resolve hostnames to multiple addresses the same as VGW_PORT. UNIX domain +# sockets are supported using the same syntax as VGW_PORT (absolute/relative +# paths and Linux abstract "@" sockets). #VGW_WEBUI_PORT= # The VGW_WEBUI_CERT and VGW_WEBUI_KEY options specify the TLS certificate and diff --git a/s3api/server_test.go b/s3api/server_test.go index c135875..7540fb1 100644 --- a/s3api/server_test.go +++ b/s3api/server_test.go @@ -30,17 +30,17 @@ func TestS3ApiServer_Serve(t *testing.T) { port string }{ { - name: "Serve-invalid-address", + name: "Serve-invalid-tcp-address", wantErr: true, sa: &S3ApiServer{ app: fiber.New(), backend: backend.BackendUnsupported{}, Router: &S3ApiRouter{}, }, - port: "Invalid address", + port: "localhost:notaport", }, { - name: "Serve-invalid-address-with-certificate", + name: "Serve-invalid-tcp-address-with-certificate", wantErr: true, sa: &S3ApiServer{ app: fiber.New(), @@ -48,7 +48,7 @@ func TestS3ApiServer_Serve(t *testing.T) { Router: &S3ApiRouter{}, CertStorage: &utils.CertStorage{}, }, - port: "Invalid address", + port: "localhost:notaport", }, } for _, tt := range tests { diff --git a/s3api/utils/multi_listener.go b/s3api/utils/multi_listener.go index 2c394a5..8f71870 100644 --- a/s3api/utils/multi_listener.go +++ b/s3api/utils/multi_listener.go @@ -19,6 +19,9 @@ import ( "errors" "fmt" "net" + "os" + "path/filepath" + "strings" "sync" ) @@ -135,10 +138,69 @@ func (ml *MultiListener) Addr() net.Addr { return nil } +// IsUnixSocketPath reports whether addr should be treated as a UNIX domain +// socket path rather than a TCP/IP address. It does so by attempting to parse +// addr as a host:port spec using net.SplitHostPort; anything that cannot be +// parsed that way (e.g. "/path/to/socket", "./rel/socket", "@abstract") is +// considered a socket path. +func IsUnixSocketPath(addr string) bool { + _, _, err := net.SplitHostPort(addr) + return err != nil +} + +// AbsSocketPaths converts any relative UNIX socket paths in addrs to absolute +// paths using the current working directory. Non-socket addresses (TCP/IP) and +// abstract sockets ("@name") are returned unchanged. This should be called +// early in program startup — before any backend that calls os.Chdir — so that +// relative paths are resolved against the shell's working directory. +func AbsSocketPaths(addrs []string) ([]string, error) { + result := make([]string, len(addrs)) + for i, addr := range addrs { + if strings.HasPrefix(addr, "./") { + abs, err := filepath.Abs(addr) + if err != nil { + return nil, fmt.Errorf("failed to resolve socket path %q: %w", addr, err) + } + result[i] = abs + } else { + result[i] = addr + } + } + return result, nil +} + +// isAbstractSocket reports whether addr is a Linux abstract namespace socket. +// Abstract sockets start with "@"; Go's net package maps this to a leading +// null byte (\0) in the sockaddr, so no socket file is created on disk. +func isAbstractSocket(addr string) bool { + return strings.HasPrefix(addr, "@") +} + +// removeStaleSocket removes a leftover UNIX socket file at path so the +// address can be reused. It returns an error if the path exists but is not +// a socket, protecting regular files and directories from accidental deletion. +func removeStaleSocket(path string) error { + fi, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to stat socket path %q: %w", path, err) + } + if fi.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("path %q already exists and is not a socket (mode %s)", path, fi.Mode()) + } + return os.Remove(path) +} + // ResolveHostnameIPs resolves a hostname to all its IP addresses (IPv4 and IPv6). // If the input is already an IP address or empty, it returns it as-is. // This is useful for determining all addresses a server will listen on. func ResolveHostnameIPs(address string) ([]string, error) { + if IsUnixSocketPath(address) { + return []string{address}, nil + } + host, _, err := net.SplitHostPort(address) if err != nil { return nil, fmt.Errorf("invalid address %q: %w", address, err) @@ -176,6 +238,10 @@ func ResolveHostnameIPs(address string) ([]string, error) { // resolveHostnameAddrs resolves a hostname to all its IP addresses (IPv4 and IPv6) // and returns them as a list of addresses with the port attached. func resolveHostnameAddrs(address string) ([]string, error) { + if IsUnixSocketPath(address) { + return []string{address}, nil + } + host, port, err := net.SplitHostPort(address) if err != nil { return nil, fmt.Errorf("invalid address %q: %w", address, err) @@ -210,7 +276,27 @@ func resolveHostnameAddrs(address string) ([]string, error) { // in the address resolves to. If the address is already an IP, it creates a // single listener. Returns a MultiListener if multiple addresses are resolved, // or a single listener if only one address is found. +// +// UNIX domain socket forms are also supported: +// - "/path/to/socket" or "./rel/socket" — file-backed socket; any stale +// socket file is removed before binding. +// - "@name" — Linux abstract namespace socket; no file is created or removed. func NewMultiAddrListener(network, address string) (net.Listener, error) { + if IsUnixSocketPath(address) { + // For file-backed sockets, remove any stale socket file so re-binding works cleanly. + // Abstract sockets (@name) have no filesystem entry; skip removal for them. + if !isAbstractSocket(address) { + if err := removeStaleSocket(address); err != nil { + return nil, err + } + } + ln, err := net.Listen("unix", address) + if err != nil { + return nil, fmt.Errorf("failed to bind unix socket listener %s: %w", address, err) + } + return NewMultiListener(ln), nil + } + addrs, err := resolveHostnameAddrs(address) if err != nil { return nil, err @@ -237,12 +323,30 @@ func NewMultiAddrListener(network, address string) (net.Listener, error) { // NewMultiAddrTLSListener creates TLS listeners for all IP addresses that the // hostname in the address resolves to. Similar to NewMultiAddrListener but with TLS. +// +// UNIX domain socket forms are also supported: +// - "/path/to/socket" or "./rel/socket" — file-backed socket; any stale +// socket file is removed before binding. +// - "@name" — Linux abstract namespace socket; no file is created or removed. func NewMultiAddrTLSListener(network, address string, getCertificateFunc func(*tls.ClientHelloInfo) (*tls.Certificate, error)) (net.Listener, error) { config := &tls.Config{ MinVersion: tls.VersionTLS12, GetCertificate: getCertificateFunc, } + if IsUnixSocketPath(address) { + if !isAbstractSocket(address) { + if err := removeStaleSocket(address); err != nil { + return nil, err + } + } + ln, err := net.Listen("unix", address) + if err != nil { + return nil, fmt.Errorf("failed to bind unix TLS socket listener %s: %w", address, err) + } + return NewMultiListener(tls.NewListener(ln, config)), nil + } + addrs, err := resolveHostnameAddrs(address) if err != nil { return nil, err diff --git a/s3api/utils/multi_listener_test.go b/s3api/utils/multi_listener_test.go index cc097c4..4729bf9 100644 --- a/s3api/utils/multi_listener_test.go +++ b/s3api/utils/multi_listener_test.go @@ -171,9 +171,12 @@ func TestResolveHostnameAddrs(t *testing.T) { }, }, { - name: "invalid address", + name: "no port treated as unix socket path", address: "invalid-no-port", - wantErr: true, + wantErr: false, + checkResult: func(addrs []string) bool { + return len(addrs) == 1 && addrs[0] == "invalid-no-port" + }, }, } @@ -235,9 +238,20 @@ func TestResolveHostnameIPs(t *testing.T) { }, }, { - name: "invalid address", - address: "invalid-no-port", - wantErr: true, + name: "unix socket path", + address: "/tmp/test.sock", + wantErr: false, + checkResult: func(ips []string) bool { + return len(ips) == 1 && ips[0] == "/tmp/test.sock" + }, + }, + { + name: "relative unix socket path", + address: "./test.sock", + wantErr: false, + checkResult: func(ips []string) bool { + return len(ips) == 1 && ips[0] == "./test.sock" + }, }, }