mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
fix(network): bind BASE_LISTEN_P2P's host, not every interface
newZapTransport handed luxfi/zap a Port and no Address, and a zap node given only a Port listens on ":<port>". So BASE_LISTEN_P2P=127.0.0.1:9999 bound 9999 on every interface and the host was dropped without a word. Pass the address itself, with Port as the port it binds. Now that the address binds verbatim, validate() refuses one with no port rather than letting it fail at Start. The parser used to fall back to 9999 on a bad value, and its comment named a check validate() never made. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+1
-1
@@ -177,7 +177,7 @@ Backend is pluggable via `github.com/hanzoai/s3` client surface:
|
||||
| `BASE_NODE_ROLE` | `validator` (default) \| `archive` | |
|
||||
| `BASE_ARCHIVE` | `gs://…` \| `s3://…` \| `off` (default) | |
|
||||
| `BASE_LISTEN_HTTP` | `:8090` default | Base HTTP. |
|
||||
| `BASE_LISTEN_P2P` | `:9999` default | quasar p2p `host:port`; the host is bound as given. |
|
||||
| `BASE_LISTEN_P2P` | `host:port`, `:9999` default | quasar p2p listener. The host is honoured: `:9999` is every interface, `127.0.0.1:9999` loopback only. |
|
||||
| `BASE_SHARD_BACKLOG_MAX` | bytes (64 MiB default) | R6 per-shard archive backlog cap; drop-oldest beyond. |
|
||||
| `BASE_SHARD_BACKLOG_SEGMENTS` | integer (100 000 default) | R6 segment-count cap; first-to-hit with MAX drops. |
|
||||
|
||||
|
||||
+5
-1
@@ -48,7 +48,8 @@ type Config struct {
|
||||
// /-/base/members endpoint by the Gateway; main HTTP comes from core.
|
||||
ListenHTTP string
|
||||
|
||||
// ListenP2P is the Quasar peer-to-peer port.
|
||||
// ListenP2P is the host:port the Quasar peer listener binds. The host is
|
||||
// honoured: ":9999" is every interface, "127.0.0.1:9999" loopback only.
|
||||
ListenP2P string
|
||||
}
|
||||
|
||||
@@ -166,6 +167,9 @@ func (c Config) validate() error {
|
||||
if strings.TrimSpace(c.NodeID) == "" {
|
||||
return fmt.Errorf("BASE_NODE_ID or $HOSTNAME must be set when BASE_NETWORK=quasar")
|
||||
}
|
||||
if _, err := listenPort(c.ListenP2P); err != nil {
|
||||
return fmt.Errorf("BASE_LISTEN_P2P=%q: must be the host:port the peer listener binds: %w", c.ListenP2P, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,40 @@ func TestFromEnvQuasarValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// BASE_LISTEN_P2P is the address the peer listener binds, host and all, so one
|
||||
// that names no port is refused rather than bound somewhere else.
|
||||
func TestFromEnvListenP2P(t *testing.T) {
|
||||
for _, s := range []struct {
|
||||
value string
|
||||
ok bool
|
||||
}{
|
||||
{":9999", true},
|
||||
{"127.0.0.1:9999", true},
|
||||
{"[::1]:9999", true},
|
||||
{"localhost:9999", true},
|
||||
{":0", true},
|
||||
{"9999", false},
|
||||
{"localhost", false},
|
||||
{"127.0.0.1:", false},
|
||||
{"127.0.0.1:p2p", false},
|
||||
{":65536", false},
|
||||
} {
|
||||
t.Run(s.value, func(t *testing.T) {
|
||||
t.Setenv("BASE_NETWORK", "quasar")
|
||||
t.Setenv("BASE_SHARD_KEY", "user_id")
|
||||
t.Setenv("HOSTNAME", "a")
|
||||
t.Setenv("BASE_LISTEN_P2P", s.value)
|
||||
_, err := ConfigFromEnv()
|
||||
if s.ok && err != nil {
|
||||
t.Fatalf("refused: %v", err)
|
||||
}
|
||||
if !s.ok && err == nil {
|
||||
t.Fatal("accepted an address with no port")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWALHookRejectsBadConn(t *testing.T) {
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
|
||||
@@ -28,6 +28,8 @@ import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -78,16 +80,24 @@ type zapTransport struct {
|
||||
// NOT bind the listener — Start does. Construction is cheap so the base
|
||||
// node can embed the transport before the base app has finished booting.
|
||||
func newZapTransport(cfg Config) *zapTransport {
|
||||
// validate() has refused an address with no port, so this is the port
|
||||
// Address binds.
|
||||
port, _ := listenPort(cfg.ListenP2P)
|
||||
|
||||
logger := luxlog.New("component", "base-network", "transport", "zap", "nodeID", cfg.NodeID)
|
||||
|
||||
// luxfi/zap uses mDNS by default for peer discovery; K8s pods don't
|
||||
// get link-local multicast so we disable it and rely on the explicit
|
||||
// BASE_PEERS list below. The node binds BASE_LISTEN_P2P as given, host
|
||||
// included; an address that does not parse fails Start.
|
||||
// BASE_PEERS list below.
|
||||
//
|
||||
// Address is what binds, host included. Given Port alone the node
|
||||
// listens on ":<port>", every interface, whatever host BASE_LISTEN_P2P
|
||||
// names. Port is then only what mDNS would advertise.
|
||||
node := zap.NewNode(zap.NodeConfig{
|
||||
NodeID: cfg.NodeID,
|
||||
ServiceType: zapServiceType,
|
||||
Address: strings.TrimSpace(cfg.ListenP2P),
|
||||
Address: cfg.ListenP2P,
|
||||
Port: port,
|
||||
NoDiscovery: true,
|
||||
})
|
||||
|
||||
@@ -261,6 +271,20 @@ func (z *zapTransport) reconnectLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// listenPort is the port in a host:port listen address. 0 is a port, the one
|
||||
// the OS picks, so ":0" is well formed; a missing or named port is not.
|
||||
func listenPort(listen string) (int, error) {
|
||||
_, p, err := net.SplitHostPort(listen)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
port, err := strconv.ParseUint(p, 10, 16)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("port %q is not a number from 0 to 65535", p)
|
||||
}
|
||||
return int(port), nil
|
||||
}
|
||||
|
||||
// buildZapEnvelopeMessage wraps the raw frame bytes in a ZAP message with
|
||||
// Flags = msgType << 8 so the receiving Node dispatches to the envelope
|
||||
// handler. The body is a single ZAP object with one `bytes` field at
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
|
||||
package network
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIsSelfPeer verifies that BASE_PEERS entries are correctly matched
|
||||
// against the local NodeID even when the peer entry carries the full
|
||||
@@ -47,3 +53,87 @@ func TestIsSelfPeer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartListensWhereListenP2PSays starts the transport from the environment
|
||||
// and dials it as a peer on another host would, on this machine's non-loopback
|
||||
// address. With BASE_LISTEN_P2P on loopback nothing answers there; with its
|
||||
// host left empty something does, which shows the probe can see an exposure at
|
||||
// all.
|
||||
func TestStartListensWhereListenP2PSays(t *testing.T) {
|
||||
lan := lanIPv4(t)
|
||||
|
||||
start := func(t *testing.T, listen string) {
|
||||
t.Helper()
|
||||
t.Setenv("BASE_NETWORK", "quasar")
|
||||
t.Setenv("BASE_SHARD_KEY", "user_id")
|
||||
t.Setenv("BASE_NODE_ID", "bind-test")
|
||||
t.Setenv("BASE_PEERS", "")
|
||||
t.Setenv("BASE_LISTEN_P2P", listen)
|
||||
cfg, err := ConfigFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigFromEnv: %v", err)
|
||||
}
|
||||
z := newZapTransport(cfg)
|
||||
if err := z.Start(context.Background(), func(Envelope) {}); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = z.Stop(context.Background()) })
|
||||
}
|
||||
|
||||
t.Run("loopback", func(t *testing.T) {
|
||||
port := freePort(t)
|
||||
start(t, net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
if !dials("127.0.0.1", port) {
|
||||
t.Fatal("nothing answers on loopback")
|
||||
}
|
||||
if dials(lan, port) {
|
||||
t.Fatalf("the peer listener answers on %s:%d with BASE_LISTEN_P2P on loopback", lan, port)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("every interface", func(t *testing.T) {
|
||||
port := freePort(t)
|
||||
start(t, ":"+strconv.Itoa(port))
|
||||
if !dials(lan, port) {
|
||||
t.Fatalf("nothing answers on %s:%d with BASE_LISTEN_P2P on every interface", lan, port)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve free port: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func dials(host string, port int) bool {
|
||||
c, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), time.Second)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
c.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// lanIPv4 is an address of this machine that is not loopback — the one a peer
|
||||
// on another host would dial.
|
||||
func lanIPv4(t *testing.T) string {
|
||||
t.Helper()
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
t.Skipf("interface addresses: %v", err)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipn, ok := a.(*net.IPNet); ok {
|
||||
if ip := ipn.IP.To4(); ip != nil && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Skip("no non-loopback IPv4 address to dial")
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user