feat: add multi-address listener for s3/admin/webui

This allows specifying the following options more than once:
port, admin-port, webui

or using a comma-separated list for the env vars:
e.g., VGW_PORT=:7070,:8080,localhost:9090

This will also expand multiple interfaces from hostnames, for example
"localhost" in this case would resolve to both IPv4 and IPv6 interfaces:
localhost has address 127.0.0.1
localhost has IPv6 address ::1

This updates the banner to reflect all of the listening interfaces/ports,
and starts the service listener on all requested interfaces/ports.

Fixes #1761
This commit is contained in:
Ben McClelland
2026-02-17 14:16:43 -08:00
parent 919436d473
commit 599ab1b743
9 changed files with 1223 additions and 197 deletions
+268
View File
@@ -0,0 +1,268 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package utils
import (
"crypto/tls"
"errors"
"fmt"
"net"
"sync"
)
// MultiListener implements net.Listener and accepts connections from multiple
// underlying listeners. This is useful for listening on multiple IP addresses
// that a hostname resolves to (e.g., both IPv4 and IPv6 for "localhost").
type MultiListener struct {
listeners []net.Listener
acceptCh chan acceptResult
closeCh chan struct{}
closeOnce sync.Once
wg sync.WaitGroup
}
type acceptResult struct {
conn net.Conn
err error
}
// NewMultiListener creates a new MultiListener that accepts connections from
// all provided listeners.
func NewMultiListener(listeners ...net.Listener) *MultiListener {
if len(listeners) == 0 {
return nil
}
ml := &MultiListener{
listeners: listeners,
acceptCh: make(chan acceptResult, 2*len(listeners)),
closeCh: make(chan struct{}),
}
// Start accepting from each listener in its own goroutine
for _, ln := range listeners {
ml.wg.Add(1)
go ml.acceptLoop(ln)
}
return ml
}
// acceptLoop continuously accepts connections from a single listener
// and forwards them to the accept channel
func (ml *MultiListener) acceptLoop(ln net.Listener) {
defer ml.wg.Done()
for {
conn, err := ln.Accept()
select {
case <-ml.closeCh:
// MultiListener is closing
if conn != nil {
conn.Close()
}
return
case ml.acceptCh <- acceptResult{conn: conn, err: err}:
// Connection or error sent successfully
if err != nil {
return
}
}
}
}
// Accept waits for and returns the next connection from any of the listeners
func (ml *MultiListener) Accept() (net.Conn, error) {
select {
case <-ml.closeCh:
return nil, errors.New("listener closed")
case result, ok := <-ml.acceptCh:
if !ok {
// Channel closed
return nil, errors.New("listener closed")
}
return result.conn, result.err
}
}
// Close closes all underlying listeners
func (ml *MultiListener) Close() error {
var errs []error
ml.closeOnce.Do(func() {
close(ml.closeCh)
// Close all listeners
for _, ln := range ml.listeners {
if err := ln.Close(); err != nil {
errs = append(errs, err)
}
}
// Wait for all accept loops to finish
ml.wg.Wait()
// Drain any remaining accepts
close(ml.acceptCh)
for range ml.acceptCh {
}
})
if len(errs) > 0 {
return fmt.Errorf("errors closing listeners: %v", errs)
}
return nil
}
// Addr returns the address of the first listener
func (ml *MultiListener) Addr() net.Addr {
if len(ml.listeners) > 0 {
return ml.listeners[0].Addr()
}
return nil
}
// ResolveHostnameIPs resolves a hostname to all its IP addresses (IPv4 and IPv6).
// If the input is already an IP address or empty, it returns it as-is.
// This is useful for determining all addresses a server will listen on.
func ResolveHostnameIPs(address string) ([]string, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid address %q: %w", address, err)
}
// Handle empty host (e.g., ":8080" means all interfaces)
if host == "" {
return []string{""}, nil
}
// If already an IP address, return as is
if net.ParseIP(host) != nil {
return []string{host}, nil
}
// Resolve hostname to all IP addresses
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("no addresses found for hostname %q", host)
}
// Convert IPs to strings
result := make([]string, 0, len(ips))
for _, ip := range ips {
result = append(result, ip.String())
}
return result, nil
}
// resolveHostnameAddrs resolves a hostname to all its IP addresses (IPv4 and IPv6)
// and returns them as a list of addresses with the port attached.
func resolveHostnameAddrs(address string) ([]string, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid address %q: %w", address, err)
}
// If host is empty or already an IP address, return as is
if host == "" || net.ParseIP(host) != nil {
return []string{address}, nil
}
// Resolve hostname to all IP addresses
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("no addresses found for hostname %q", host)
}
// Build list of addresses with port
addrs := make([]string, 0, len(ips))
for _, ip := range ips {
addr := net.JoinHostPort(ip.String(), port)
addrs = append(addrs, addr)
}
return addrs, nil
}
// NewMultiAddrListener creates listeners for all IP addresses that the hostname
// in the address resolves to. If the address is already an IP, it creates a
// single listener. Returns a MultiListener if multiple addresses are resolved,
// or a single listener if only one address is found.
func NewMultiAddrListener(network, address string) (net.Listener, error) {
addrs, err := resolveHostnameAddrs(address)
if err != nil {
return nil, err
}
// Create listeners for all resolved addresses
listeners := make([]net.Listener, 0, len(addrs))
for _, addr := range addrs {
ln, err := net.Listen(network, addr)
if err != nil {
// Close any listeners we've already created
for _, l := range listeners {
l.Close()
}
return nil, fmt.Errorf("failed to bind listener %s: %w", addr, err)
}
listeners = append(listeners, ln)
}
// Return MultiListener for multiple addresses
return NewMultiListener(listeners...), nil
}
// NewMultiAddrTLSListener creates TLS listeners for all IP addresses that the
// hostname in the address resolves to. Similar to NewMultiAddrListener but with TLS.
func NewMultiAddrTLSListener(network, address string, getCertificateFunc func(*tls.ClientHelloInfo) (*tls.Certificate, error)) (net.Listener, error) {
config := &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: getCertificateFunc,
}
addrs, err := resolveHostnameAddrs(address)
if err != nil {
return nil, err
}
// Create TLS listeners for all resolved addresses
listeners := make([]net.Listener, 0, len(addrs))
for _, addr := range addrs {
ln, err := net.Listen(network, addr)
if err != nil {
// Close any listeners we've already created
for _, l := range listeners {
l.Close()
}
return nil, fmt.Errorf("failed to bind TLS listener %s: %w", addr, err)
}
listeners = append(listeners, tls.NewListener(ln, config))
}
// Return MultiListener for multiple addresses
return NewMultiListener(listeners...), nil
}
+393
View File
@@ -0,0 +1,393 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package utils
import (
"crypto/tls"
"io"
"net"
"testing"
"time"
)
func TestMultiListener(t *testing.T) {
// Create multiple underlying listeners
ln1, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener 1: %v", err)
}
defer ln1.Close()
ln2, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener 2: %v", err)
}
defer ln2.Close()
// Create MultiListener
ml := NewMultiListener(ln1, ln2)
if ml == nil {
t.Fatal("NewMultiListener returned nil")
}
defer ml.Close()
// Test connections to both listeners
addr1 := ln1.Addr().String()
addr2 := ln2.Addr().String()
// Connect to first listener
go func() {
conn, err := net.Dial("tcp", addr1)
if err != nil {
t.Errorf("Failed to dial first address: %v", err)
return
}
defer conn.Close()
conn.Write([]byte("hello from ln1"))
}()
// Accept from MultiListener
conn1, err := ml.Accept()
if err != nil {
t.Fatalf("Failed to accept from MultiListener: %v", err)
}
defer conn1.Close()
buf := make([]byte, 100)
n, _ := conn1.Read(buf)
if string(buf[:n]) != "hello from ln1" {
t.Errorf("Unexpected data from first connection: %s", string(buf[:n]))
}
// Connect to second listener
go func() {
conn, err := net.Dial("tcp", addr2)
if err != nil {
t.Errorf("Failed to dial second address: %v", err)
return
}
defer conn.Close()
conn.Write([]byte("hello from ln2"))
}()
// Accept from MultiListener
conn2, err := ml.Accept()
if err != nil {
t.Fatalf("Failed to accept second connection: %v", err)
}
defer conn2.Close()
n, _ = conn2.Read(buf)
if string(buf[:n]) != "hello from ln2" {
t.Errorf("Unexpected data from second connection: %s", string(buf[:n]))
}
}
func TestMultiListenerClose(t *testing.T) {
ln1, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
ml := NewMultiListener(ln1)
if ml == nil {
t.Fatal("NewMultiListener returned nil")
}
// Start accepting in a goroutine
acceptErrors := make(chan error, 1)
go func() {
_, err := ml.Accept()
acceptErrors <- err
}()
// Give the accept goroutine time to start
time.Sleep(100 * time.Millisecond)
// Close the MultiListener
if err := ml.Close(); err != nil {
t.Errorf("Close() returned error: %v", err)
}
// The accept should now return an error
select {
case err := <-acceptErrors:
if err == nil {
t.Error("Accept() should fail after Close()")
}
case <-time.After(2 * time.Second):
t.Error("Accept() did not return after Close()")
}
// Try to accept after close - should fail immediately
_, err = ml.Accept()
if err == nil {
t.Error("Accept() should fail after Close() on subsequent calls")
}
}
func TestResolveHostnameAddrs(t *testing.T) {
tests := []struct {
name string
address string
wantErr bool
checkResult func([]string) bool
}{
{
name: "IPv4 address",
address: "127.0.0.1:8080",
wantErr: false,
checkResult: func(addrs []string) bool {
return len(addrs) == 1 && addrs[0] == "127.0.0.1:8080"
},
},
{
name: "IPv6 address",
address: "[::1]:8080",
wantErr: false,
checkResult: func(addrs []string) bool {
return len(addrs) == 1 && addrs[0] == "[::1]:8080"
},
},
{
name: "localhost hostname",
address: "localhost:8080",
wantErr: false,
checkResult: func(addrs []string) bool {
// localhost should resolve to at least one address
return len(addrs) >= 1
},
},
{
name: "invalid address",
address: "invalid-no-port",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
addrs, err := resolveHostnameAddrs(tt.address)
if (err != nil) != tt.wantErr {
t.Errorf("resolveHostnameAddrs() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.checkResult != nil {
if !tt.checkResult(addrs) {
t.Errorf("resolveHostnameAddrs() returned unexpected result: %v", addrs)
}
}
})
}
}
func TestResolveHostnameIPs(t *testing.T) {
tests := []struct {
name string
address string
wantErr bool
checkResult func([]string) bool
}{
{
name: "IPv4 address",
address: "127.0.0.1:8080",
wantErr: false,
checkResult: func(ips []string) bool {
return len(ips) == 1 && ips[0] == "127.0.0.1"
},
},
{
name: "IPv6 address",
address: "[::1]:8080",
wantErr: false,
checkResult: func(ips []string) bool {
return len(ips) == 1 && ips[0] == "::1"
},
},
{
name: "localhost hostname",
address: "localhost:8080",
wantErr: false,
checkResult: func(ips []string) bool {
// localhost should resolve to at least one address
// On most systems, it resolves to both 127.0.0.1 and ::1
return len(ips) >= 1
},
},
{
name: "empty host",
address: ":8080",
wantErr: false,
checkResult: func(ips []string) bool {
return len(ips) == 1 && ips[0] == ""
},
},
{
name: "invalid address",
address: "invalid-no-port",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ips, err := ResolveHostnameIPs(tt.address)
if (err != nil) != tt.wantErr {
t.Errorf("ResolveHostnameIPs() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.checkResult != nil {
if !tt.checkResult(ips) {
t.Errorf("ResolveHostnameIPs() returned unexpected result: %v", ips)
}
}
})
}
}
func TestNewMultiAddrListener(t *testing.T) {
tests := []struct {
name string
address string
wantErr bool
}{
{
name: "IPv4 loopback",
address: "127.0.0.1:0",
wantErr: false,
},
{
name: "IPv6 loopback",
address: "[::1]:0",
wantErr: false,
},
{
name: "localhost with port",
address: "localhost:0",
wantErr: false,
},
{
name: "invalid hostname",
address: "this-hostname-should-not-exist-12345.invalid:8080",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ln, err := NewMultiAddrListener("tcp", tt.address)
if (err != nil) != tt.wantErr {
t.Errorf("NewMultiAddrListener() error = %v, wantErr %v", err, tt.wantErr)
return
}
if ln != nil {
defer ln.Close()
// Try to connect to verify listener is working
addr := ln.Addr().String()
go func() {
conn, err := net.Dial("tcp", addr)
if err != nil {
return
}
conn.Close()
}()
// Accept connection with timeout
type result struct {
conn net.Conn
err error
}
ch := make(chan result, 1)
go func() {
conn, err := ln.Accept()
ch <- result{conn, err}
}()
select {
case res := <-ch:
if res.err != nil {
t.Errorf("Failed to accept connection: %v", res.err)
}
if res.conn != nil {
res.conn.Close()
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for connection")
}
}
})
}
}
func TestNewMultiAddrTLSListener(t *testing.T) {
// Create a simple test certificate
getCertFunc := func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.X509KeyPair([]byte(testCert), []byte(testKey))
return &cert, err
}
ln, err := NewMultiAddrTLSListener("tcp", "127.0.0.1:0", getCertFunc)
if err != nil {
t.Fatalf("NewMultiAddrTLSListener() error = %v", err)
}
defer ln.Close()
addr := ln.Addr().String()
// Try to connect with TLS
go func() {
conn, err := tls.Dial("tcp", addr, &tls.Config{
InsecureSkipVerify: true,
})
if err != nil {
return
}
conn.Write([]byte("test"))
conn.Close()
}()
// Accept connection
conn, err := ln.Accept()
if err != nil {
t.Fatalf("Failed to accept TLS connection: %v", err)
}
defer conn.Close()
buf := make([]byte, 100)
_, err = io.ReadAtLeast(conn, buf, 4)
if err != nil {
t.Errorf("Failed to read from TLS connection: %v", err)
}
}
// Test certificate and key for TLS tests
const testCert = `-----BEGIN CERTIFICATE-----
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
6MF9+Yw1Yy0t
-----END CERTIFICATE-----`
const testKey = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49
AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q
EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
-----END EC PRIVATE KEY-----`