s3: register the advertised ip with the master (#10482)

* s3: register the advertised ip with the master

The cluster address came from the bind ip, falling back to the
auto-detected interface, so -ip never reached the S3 registration.
weed mini -ip=localhost binds the wildcard and ended up registering
whatever interface happened to sort first -- on a host with VPN
interfaces, an address that stops routing once the tunnel drops.

IAM changes are pushed to registered S3 servers over gRPC, so every
mutation then blocked the full 10s propagation deadline before logging
a failure, and cluster.ps and the admin UI listed a node nothing could
reach. Identities still arrived through the /etc/iam metadata
subscription, so this cost latency and visibility, not credentials.

Add an advertise ip to the gateway option, preferring it over the bind
address, and wire the parent -ip through server, filer and mini.

* s3: treat any unspecified bind address as a wildcard

net.ParseIP + IsUnspecified covers ::, [::] and the expanded IPv6 forms
instead of only the 0.0.0.0 literal, so an IPv6 wildcard bind no longer
registers an address peers cannot dial. Host names parse as nil and stay
addresses in their own right. Apply the same guard to the advertised ip.
This commit is contained in:
Chris Lu
2026-07-29 10:30:46 -07:00
committed by GitHub
parent 0002e5cc7f
commit 4149346bb7
6 changed files with 72 additions and 7 deletions
+1
View File
@@ -257,6 +257,7 @@ func runFiler(cmd *Command, args []string) bool {
startDelay := time.Duration(2)
if *filerStartS3 {
filerS3Options.filer = &filerAddress
filerS3Options.ip = f.ip
if *filerS3Options.bindIp == "" {
filerS3Options.bindIp = f.bindIp
}
+1
View File
@@ -1223,6 +1223,7 @@ func runMini(cmd *Command, args []string) bool {
miniFilerOptions.masters = pb.ServerAddresses(actualPeersForComponents).ToServiceDiscovery()
miniFilerOptions.ip = miniIp
miniFilerOptions.bindIp = miniBindIp
miniS3Options.ip = miniIp
miniS3Options.bindIp = miniBindIp
miniWebDavOptions.ipBind = miniBindIp
miniOptions.v.ip = miniIp
+3
View File
@@ -42,6 +42,7 @@ var (
// When adding a new field, update all four flag registration sites.
type S3Options struct {
filer *string
ip *string
bindIp *string
port *int
portHttps *int
@@ -83,6 +84,7 @@ type S3Options struct {
func init() {
cmdS3.Run = runS3 // break init cycle
s3StandaloneOptions.filer = cmdS3.Flag.String("filer", "localhost:8888", "comma-separated filer server addresses for high availability")
s3StandaloneOptions.ip = cmdS3.Flag.String("ip", "", "ip address advertised to the cluster. If empty, default to -ip.bind, or the auto-detected address.")
s3StandaloneOptions.bindIp = cmdS3.Flag.String("ip.bind", "", "ip address to bind to. If empty, default to 0.0.0.0.")
s3StandaloneOptions.port = cmdS3.Flag.Int("port", 8333, "s3 server http listen port")
s3StandaloneOptions.portHttps = cmdS3.Flag.Int("port.https", 0, "s3 server https listen port")
@@ -358,6 +360,7 @@ func (s3opt *S3Options) startS3Server() bool {
EnableIam: *s3opt.enableIam, // Embedded IAM API (enabled by default)
IamReadOnly: *s3opt.iamReadOnly,
Cipher: *s3opt.cipher, // encrypt data on volume servers
Ip: *s3opt.ip,
BindIp: *s3opt.bindIp,
GrpcPort: *s3opt.portGrpc,
ExternalUrl: s3opt.resolveExternalUrl(),
+1
View File
@@ -289,6 +289,7 @@ func runServer(cmd *Command, args []string) bool {
filerOptions.masters = pb.ServerAddresses(actualPeersForComponents).ToServiceDiscovery()
filerOptions.ip = serverIp
filerOptions.bindIp = serverBindIp
s3Options.ip = serverIp
if *s3Options.bindIp == "" {
s3Options.bindIp = serverBindIp
}
+29 -7
View File
@@ -58,9 +58,10 @@ type S3ApiServerOption struct {
IamConfig string // Advanced IAM configuration file path
ConcurrentUploadLimit int64
ConcurrentFileUploadLimit int64
EnableIam bool // Enable embedded IAM API on the same port
IamReadOnly bool // Disable IAM write operations on this server
Cipher bool // encrypt data on volume servers
EnableIam bool // Enable embedded IAM API on the same port
IamReadOnly bool // Disable IAM write operations on this server
Cipher bool // encrypt data on volume servers
Ip string // address advertised to the cluster; empty falls back to BindIp
BindIp string
GrpcPort int
ExternalUrl string // external URL clients use, for signature verification behind a reverse proxy
@@ -132,6 +133,30 @@ func NewS3ApiServer(router *mux.Router, option *S3ApiServerOption) (s3ApiServer
return NewS3ApiServerWithStore(router, option, "")
}
// advertisedHost is the address this server registers with the master, which is
// how peers reach it — IAM changes are pushed to it over gRPC. It must be the
// advertised -ip, not the bind address: binding 0.0.0.0 and registering the
// auto-detected interface makes those pushes dial a host that may not route
// back here at all (a VPN address, a container-internal IP), and the push then
// fails silently after a 10s deadline.
func (option *S3ApiServerOption) advertisedHost() string {
if option.Ip != "" && !isWildcardHost(option.Ip) {
return option.Ip
}
if option.BindIp != "" && !isWildcardHost(option.BindIp) {
return option.BindIp
}
return util.DetectedHostAddress()
}
// isWildcardHost reports whether host is an unspecified address (0.0.0.0, ::,
// [::]) — one that accepts connections but tells a peer nothing about where to
// reach us. Host names parse as nil and are addresses in their own right.
func isWildcardHost(host string) bool {
ip := net.ParseIP(strings.TrimSuffix(strings.TrimPrefix(host, "["), "]"))
return ip != nil && ip.IsUnspecified()
}
func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, explicitStore string) (s3ApiServer *S3ApiServer, err error) {
if len(option.Filers) == 0 {
return nil, fmt.Errorf("at least one filer address is required")
@@ -174,10 +199,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
for i, addr := range option.Masters {
masterMap[fmt.Sprintf("master%d", i)] = addr
}
clientHost := option.BindIp
if clientHost == "0.0.0.0" || clientHost == "" {
clientHost = util.DetectedHostAddress()
}
clientHost := option.advertisedHost()
masterClient = wdclient.NewMasterClient(option.GrpcDialOption, option.FilerGroup, cluster.S3Type, pb.ServerAddress(util.JoinHostPort(clientHost, option.GrpcPort)), option.DataCenter, "", *pb.NewServiceDiscoveryFromMap(masterMap))
// Build the object-write lock client and subscribe to the master's
// lock-ring updates BEFORE starting the master loop, so the initial
+37
View File
@@ -0,0 +1,37 @@
package s3api
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func TestAdvertisedHost(t *testing.T) {
detected := util.DetectedHostAddress()
for _, tc := range []struct {
name string
ip string
bindIp string
want string
}{
{"advertised ip wins over wildcard bind", "localhost", "0.0.0.0", "localhost"},
{"advertised ip wins over specific bind", "s3.example.com", "10.0.0.5", "s3.example.com"},
{"bind ip used when no advertised ip", "", "10.0.0.5", "10.0.0.5"},
{"ipv6 bind ip used when no advertised ip", "", "2001:db8::1", "2001:db8::1"},
{"wildcard bind falls back to detected", "", "0.0.0.0", detected},
{"ipv6 wildcard bind falls back to detected", "", "::", detected},
{"bracketed ipv6 wildcard bind falls back to detected", "", "[::]", detected},
{"expanded ipv6 wildcard bind falls back to detected", "", "0:0:0:0:0:0:0:0", detected},
{"wildcard advertised ip falls back to bind ip", "0.0.0.0", "10.0.0.5", "10.0.0.5"},
{"wildcard advertised ip and bind fall back to detected", "::", "0.0.0.0", detected},
{"empty falls back to detected", "", "", detected},
} {
t.Run(tc.name, func(t *testing.T) {
option := &S3ApiServerOption{Ip: tc.ip, BindIp: tc.bindIp}
if got := option.advertisedHost(); got != tc.want {
t.Errorf("advertisedHost() = %q, want %q", got, tc.want)
}
})
}
}