diff --git a/weed/command/filer.go b/weed/command/filer.go index bc9098ba8..ee7e290d3 100644 --- a/weed/command/filer.go +++ b/weed/command/filer.go @@ -335,6 +335,7 @@ func (fo *FilerOptions) startFiler() { if *fo.bindIp == "" { *fo.bindIp = *fo.ip } + util.SetOutboundLocalIP(*fo.bindIp) if *fo.allowedOrigins == "" { *fo.allowedOrigins = "*" } diff --git a/weed/command/master.go b/weed/command/master.go index d6675ac4b..20169eed9 100644 --- a/weed/command/master.go +++ b/weed/command/master.go @@ -188,6 +188,7 @@ func startMaster(masterOption MasterOptions, masterWhiteList []string) { if *masterOption.ipBind == "" { *masterOption.ipBind = *masterOption.ip } + util.SetOutboundLocalIP(*masterOption.ipBind) myMasterAddress, peers := checkPeers(*masterOption.ip, *masterOption.port, *masterOption.portGrpc, *masterOption.peers) diff --git a/weed/command/s3.go b/weed/command/s3.go index cf33cdc7b..5037da9cb 100644 --- a/weed/command/s3.go +++ b/weed/command/s3.go @@ -324,6 +324,7 @@ func (s3opt *S3Options) startS3Server() bool { if *s3opt.bindIp == "" { *s3opt.bindIp = "0.0.0.0" } + util.SetOutboundLocalIP(*s3opt.bindIp) defaultFileMode, fileModeErr := s3opt.parseDefaultFileMode() if fileModeErr != nil { diff --git a/weed/command/server.go b/weed/command/server.go index b178cde30..2866fc298 100644 --- a/weed/command/server.go +++ b/weed/command/server.go @@ -274,6 +274,9 @@ func runServer(cmd *Command, args []string) bool { if *serverBindIp == "" { serverBindIp = serverIp } + // Bind outbound connections to the same address up front so every + // component started below inherits it, before any of them dials. + util.SetOutboundLocalIP(*serverBindIp) if *serverMetricsHttpIp == "" { *serverMetricsHttpIp = *serverBindIp diff --git a/weed/command/sftp.go b/weed/command/sftp.go index e15f6ad4d..ed4a28b79 100644 --- a/weed/command/sftp.go +++ b/weed/command/sftp.go @@ -110,6 +110,7 @@ func (sftpOpt *SftpOptions) startSftpServer() bool { if *sftpOpt.bindIp == "" { *sftpOpt.bindIp = "0.0.0.0" } + util.SetOutboundLocalIP(*sftpOpt.bindIp) filerAddress := pb.ServerAddress(*sftpOpt.filer) grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client") diff --git a/weed/command/volume.go b/weed/command/volume.go index bca02d940..1dea61c34 100644 --- a/weed/command/volume.go +++ b/weed/command/volume.go @@ -380,6 +380,7 @@ func (v VolumeServerOptions) startVolumeServer(volumeFolders, maxVolumeCounts, v if *v.bindIp == "" { *v.bindIp = *v.ip } + util.SetOutboundLocalIP(*v.bindIp) if *v.publicPort == 0 { *v.publicPort = *v.port diff --git a/weed/command/webdav.go b/weed/command/webdav.go index 71141b304..4df1e1fce 100644 --- a/weed/command/webdav.go +++ b/weed/command/webdav.go @@ -89,6 +89,8 @@ func (wo *WebDavOption) resolvePaths() { func (wo *WebDavOption) startWebDav() bool { + util.SetOutboundLocalIP(*wo.ipBind) + // detect current user uid, gid := uint32(0), uint32(0) if u, err := user.Current(); err == nil { diff --git a/weed/pb/grpc_client_server.go b/weed/pb/grpc_client_server.go index 36db5579a..c053e37f5 100644 --- a/weed/pb/grpc_client_server.go +++ b/weed/pb/grpc_client_server.go @@ -73,8 +73,13 @@ type versionedGrpcClient struct { } func init() { - http.DefaultTransport.(*http.Transport).MaxIdleConnsPerHost = 1024 - http.DefaultTransport.(*http.Transport).MaxIdleConns = 1024 + t := http.DefaultTransport.(*http.Transport) + t.MaxIdleConnsPerHost = 1024 + t.MaxIdleConns = 1024 + // Bind outbound HTTP to the -ip.bind source address. Reads the setting + // per dial, so it applies regardless of when SetOutboundLocalIP runs + // relative to this init. + t.DialContext = util.OutboundDialContext } // RegisterLocalGrpcSocket registers a Unix socket path for a gRPC service @@ -207,6 +212,13 @@ func GrpcDial(ctx context.Context, address string, waitForReady bool, opts ...gr var d net.Dialer return d.DialContext(ctx, "unix", socketPath) })) + } else if util.OutboundLocalAddr() != nil { + // Bind outbound gRPC connections to the configured -ip.bind source + // address. Only installed when a source address is set, so default + // deployments keep gRPC's stock dialer behavior. + options = append(options, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { + return util.OutboundDialContext(ctx, "tcp", addr) + })) } options = append(options, diff --git a/weed/util/http/client/http_client.go b/weed/util/http/client/http_client.go index ebc938ad3..172566c3b 100644 --- a/weed/util/http/client/http_client.go +++ b/weed/util/http/client/http_client.go @@ -165,6 +165,8 @@ func NewHttpClient(clientName ClientName, opts ...HttpClientOpt) (*HTTPClient, e MaxIdleConns: 1024, MaxIdleConnsPerHost: 1024, TLSClientConfig: tlsConfig, + // Bind outbound HTTP to the -ip.bind source address. + DialContext: util.OutboundDialContext, } httpClient.Client = &http.Client{ Transport: httpClient.Transport, @@ -279,6 +281,8 @@ func NewHttpClientWithTLS(certFile, keyFile, caFile string, insecureSkipVerify b MaxIdleConns: 1024, MaxIdleConnsPerHost: 1024, TLSClientConfig: tlsConfig, + // Bind outbound HTTP to the -ip.bind source address. + DialContext: util.OutboundDialContext, } httpClient.Client = &http.Client{ Transport: httpClient.Transport, diff --git a/weed/util/outbound_dial.go b/weed/util/outbound_dial.go new file mode 100644 index 000000000..e2159edfb --- /dev/null +++ b/weed/util/outbound_dial.go @@ -0,0 +1,100 @@ +package util + +import ( + "context" + "net" + "strings" + "sync/atomic" + "time" +) + +// outboundLocalAddr is the optional source address that outbound TCP +// connections initiated by this process bind to. It mirrors the -ip.bind +// setting so connections this process opens leave from the configured +// address instead of one the OS picks arbitrarily, which may not even be able +// to reach the target. A nil value keeps the historical behavior of letting +// the OS choose the source address. +var ( + outboundLocalAddr atomic.Pointer[net.TCPAddr] + outboundLocalAddrSet atomic.Bool +) + +// SetOutboundLocalIP records ip as the source address for outbound TCP +// connections. An empty, wildcard (0.0.0.0 / ::), or unparseable value leaves +// the source address to the OS. +// +// The first call wins. `weed server` applies the top-level -ip.bind before its +// embedded master/volume/filer/s3/... start, so a component's own (possibly +// different) bind setting can't later clobber the process-wide source address +// the others already dial from. +func SetOutboundLocalIP(ip string) { + if !outboundLocalAddrSet.CompareAndSwap(false, true) { + return + } + parsed := net.ParseIP(ip) + if parsed == nil || parsed.IsUnspecified() { + return + } + outboundLocalAddr.Store(&net.TCPAddr{IP: parsed}) +} + +// OutboundLocalAddr returns the configured source address for outbound TCP +// connections, or nil if none is configured. +func OutboundLocalAddr() *net.TCPAddr { + return outboundLocalAddr.Load() +} + +// OutboundDialContext dials network/address with the standard library's +// default timeouts, binding the local (source) address to the configured +// -ip.bind value for non-loopback tcp targets. Loopback targets and non-tcp +// networks (e.g. unix sockets) keep the OS-chosen source address: a routable +// bind IP cannot originate a packet on the loopback interface. +func OutboundDialContext(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } + if localAddr := outboundLocalAddrForDial(network, address); localAddr != nil { + d.LocalAddr = localAddr + } + return d.DialContext(ctx, network, address) +} + +// outboundLocalAddrForDial returns the source address to bind for a dial to +// network/address, or nil to leave the source address to the OS. +func outboundLocalAddrForDial(network, address string) *net.TCPAddr { + localAddr := OutboundLocalAddr() + if localAddr == nil || !strings.HasPrefix(network, "tcp") || isLoopbackHost(address) { + return nil + } + // A source address can only originate a connection to a target of the same + // IP family. When the target is a literal IP of the other family, leave the + // source to the OS rather than forcing a dial that is bound to fail. + if host, _, err := net.SplitHostPort(address); err == nil { + if targetIP := net.ParseIP(host); targetIP != nil && isIPv4(targetIP) != isIPv4(localAddr.IP) { + return nil + } + } + return localAddr +} + +// isIPv4 reports whether ip is an IPv4 (or IPv4-mapped) address. +func isIPv4(ip net.IP) bool { + return ip.To4() != nil +} + +// isLoopbackHost reports whether the host part of a "host:port" (or bare host) +// address refers to the loopback interface. +func isLoopbackHost(address string) bool { + host, _, err := net.SplitHostPort(address) + if err != nil { + host = address + } + if strings.EqualFold(host, "localhost") { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} diff --git a/weed/util/outbound_dial_test.go b/weed/util/outbound_dial_test.go new file mode 100644 index 000000000..98d7d8590 --- /dev/null +++ b/weed/util/outbound_dial_test.go @@ -0,0 +1,129 @@ +package util + +import ( + "testing" +) + +// resetOutbound clears the process-global outbound source address so each test +// starts from a clean slate (SetOutboundLocalIP is otherwise first-write-wins). +func resetOutbound() { + outboundLocalAddrSet.Store(false) + outboundLocalAddr.Store(nil) +} + +func TestSetOutboundLocalIP(t *testing.T) { + t.Cleanup(resetOutbound) + + cases := []struct { + name string + ip string + wantIP string // empty means no binding + }{ + {"ipv4", "10.0.0.5", "10.0.0.5"}, + {"ipv6", "fe80::1", "fe80::1"}, + {"empty", "", ""}, + {"wildcard v4", "0.0.0.0", ""}, + {"wildcard v6", "::", ""}, + {"garbage", "not-an-ip", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resetOutbound() + SetOutboundLocalIP(tc.ip) + got := OutboundLocalAddr() + if tc.wantIP == "" { + if got != nil { + t.Fatalf("expected no bound address, got %v", got) + } + return + } + if got == nil { + t.Fatalf("expected bound address %s, got nil", tc.wantIP) + } + if got.IP.String() != tc.wantIP { + t.Fatalf("bound address = %s, want %s", got.IP.String(), tc.wantIP) + } + if got.Port != 0 { + t.Fatalf("bound port = %d, want 0 (ephemeral)", got.Port) + } + }) + } +} + +// TestSetOutboundLocalIPFirstWins guards the behavior that keeps `weed server` +// from letting a component's own bind setting clobber the process-wide address. +func TestSetOutboundLocalIPFirstWins(t *testing.T) { + t.Cleanup(resetOutbound) + resetOutbound() + + SetOutboundLocalIP("10.0.0.5") // server-level bind, applied first + SetOutboundLocalIP("10.0.0.9") // a component's own bind: must be ignored + SetOutboundLocalIP("") // a component clearing: must not unbind + + got := OutboundLocalAddr() + if got == nil || got.IP.String() != "10.0.0.5" { + t.Fatalf("first call should win, got %v", got) + } +} + +func TestOutboundLocalAddrForDial(t *testing.T) { + t.Cleanup(resetOutbound) + + // No bind configured: never binds a source address. + resetOutbound() + if got := outboundLocalAddrForDial("tcp", "10.0.0.9:8080"); got != nil { + t.Fatalf("unconfigured dial should not bind, got %v", got) + } + + resetOutbound() + SetOutboundLocalIP("10.0.0.5") + cases := []struct { + name string + network string + address string + wantBind bool + }{ + {"remote tcp binds", "tcp", "10.0.0.9:8080", true}, + {"tcp4 binds", "tcp4", "example.com:443", true}, + {"hostname binds", "tcp", "filer.internal:8888", true}, + {"loopback ip skipped", "tcp", "127.0.0.1:9333", false}, + {"loopback name skipped", "tcp", "localhost:9333", false}, + {"ipv6 loopback skipped", "tcp", "[::1]:9333", false}, + {"ipv6 literal target skipped", "tcp", "[2001:db8::1]:8080", false}, + {"unix network skipped", "unix", "/tmp/x.sock", false}, + {"udp network skipped", "udp", "10.0.0.9:53", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := outboundLocalAddrForDial(tc.network, tc.address) + if tc.wantBind && got == nil { + t.Fatalf("%s/%s: expected source binding, got nil", tc.network, tc.address) + } + if !tc.wantBind && got != nil { + t.Fatalf("%s/%s: expected no source binding, got %v", tc.network, tc.address, got) + } + }) + } +} + +func TestOutboundLocalAddrFamilyMismatch(t *testing.T) { + t.Cleanup(resetOutbound) + + resetOutbound() + SetOutboundLocalIP("10.0.0.5") // IPv4 source + if got := outboundLocalAddrForDial("tcp", "[2001:db8::1]:8080"); got != nil { + t.Fatalf("IPv4 source to IPv6 literal target should skip binding, got %v", got) + } + if got := outboundLocalAddrForDial("tcp", "10.0.0.9:8080"); got == nil { + t.Fatalf("IPv4 source to IPv4 literal target should bind") + } + + resetOutbound() + SetOutboundLocalIP("2001:db8::5") // IPv6 source + if got := outboundLocalAddrForDial("tcp", "10.0.0.9:8080"); got != nil { + t.Fatalf("IPv6 source to IPv4 literal target should skip binding, got %v", got) + } + if got := outboundLocalAddrForDial("tcp", "[2001:db8::1]:8080"); got == nil { + t.Fatalf("IPv6 source to IPv6 literal target should bind") + } +}