Merge pull request #1932 from versity/ben/uds-listener

feat: add unix domain socket listener support to port option
This commit is contained in:
Ben McClelland
2026-03-09 09:06:47 -07:00
committed by GitHub
5 changed files with 233 additions and 19 deletions

View File

@@ -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 <ip>:<port> or :<port> (can be specified multiple times for listening on multiple addresses)",
Usage: "gateway listen address: <ip>:<port>, :<port>, /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 <ip>:<port> or :<port> (can be specified multiple times for listening on multiple addresses)",
Usage: "gateway admin server listen address: <ip>:<port>, :<port>, /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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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"
},
},
}