diff --git a/CHANGELOG.md b/CHANGELOG.md index f31ff3f4..e58f67d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -271,6 +271,18 @@ Detailed per-release notes are on the `~/.pilot/update-state.json`. ### Fixed +- **`-advertise-endpoint` survives a re-registration.** When the daemon + re-registered (registry reconnect, transport watchdog recovery) it sent + the tunnel socket's local address instead of the advertised endpoint, so + the registry replaced the operator's address with `:`. The override is now applied on re-registration as it is at start. +- **A node on a private network no longer reports a loopback endpoint.** With + the beacon and registry on the node's own private network (a container on + a Docker bridge, a lab LAN) the daemon logged `daemon registered ... + endpoint=[::1]:` and `pilotctl info` showed the same, although the + registry had recorded — and peers resolved — the node's private address + with the real tunnel port. The daemon now reports that address. Nothing + sent to the registry changes. - **Datagrams no longer use up the daemon's ports.** Every datagram sent (`pilotctl dgram`, `SendTo`, broadcasts) took an ephemeral source port and never gave it back, because only closing a connection released one. After diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index cd368cb8..6b0d6a86 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -1122,6 +1122,17 @@ func (d *Daemon) Start() error { registrationAddr = net.JoinHostPort(obsHost, stunPort) slog.Info("using registry-observed IP", "observed", obsHost) } + } else if obsIP != nil && obsIP.IsPrivate() { + // Beacon and registry are both on our private network (container + // bridge, lab LAN): the private STUN result was discarded above + // and we sent the loopback form of the tunnel socket, whose host + // the registry replaced with the one it observed. Report what + // peers actually resolve instead of loopback. + regHost, regPort, _ := net.SplitHostPort(registrationAddr) + if regIP := net.ParseIP(regHost); regIP != nil && regIP.IsLoopback() { + registrationAddr = net.JoinHostPort(obsHost, regPort) + slog.Info("using registry-observed private IP", "observed", obsHost) + } } } @@ -5567,6 +5578,11 @@ func (d *Daemon) reRegister() { registrationAddr = resolveLocalAddr(d.tunnels.LocalAddr().String()) } } + // Same override as Start (step 1b): an operator-set advertised + // endpoint must survive a re-registration. + if d.config.AdvertiseEndpoint != "" { + registrationAddr = d.config.AdvertiseEndpoint + } // Always re-register with client-generated key. // Hold identityMu.RLock for the snapshot so RotateKey (which diff --git a/pkg/daemon/zz_reregister_advertise_test.go b/pkg/daemon/zz_reregister_advertise_test.go new file mode 100644 index 00000000..22cae208 --- /dev/null +++ b/pkg/daemon/zz_reregister_advertise_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemon + +import ( + "os" + "testing" + + registry "github.com/pilot-protocol/common/registry/client" +) + +// TestReRegisterKeepsAdvertiseEndpoint verifies that a re-registration +// (registry reconnect, rx-watchdog soft recovery) sends the operator's +// -advertise-endpoint again, like Start does, instead of replacing the +// registered endpoint with the tunnel socket's local address. +func TestReRegisterKeepsAdvertiseEndpoint(t *testing.T) { + t.Parallel() + reg, rc := startTestRegistry(t) + t.Cleanup(func() { reg.Close() }) + rc.Close() + + sockDir, err := os.MkdirTemp("", "pds") + if err != nil { + t.Fatalf("mkdtemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(sockDir) }) + + const advertised = "203.0.113.9:4000" + d := New(Config{ + ListenAddr: "127.0.0.1:0", + RegistryAddr: reg.Addr().String(), + SocketPath: sockDir + "/s", + IdentityPath: t.TempDir() + "/i", + Email: "advertise@example.test", + AdvertiseEndpoint: advertised, + Public: true, // lookup returns real_addr for public nodes + DisablePolicyRunner: true, + }) + if err := d.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer d.Stop() + + lookup, err := registry.Dial(reg.Addr().String()) + if err != nil { + t.Fatalf("dial registry: %v", err) + } + defer lookup.Close() + registered := func() string { + t.Helper() + resp, err := lookup.Lookup(d.NodeID()) + if err != nil { + t.Fatalf("lookup: %v", err) + } + addr, _ := resp["real_addr"].(string) + return addr + } + + if got := registered(); got != advertised { + t.Fatalf("after Start: registered endpoint = %q, want %q", got, advertised) + } + d.reRegister() + if got := registered(); got != advertised { + t.Fatalf("after reRegister: registered endpoint = %q, want %q", got, advertised) + } +} diff --git a/tests/zz_private_endpoint_test.go b/tests/zz_private_endpoint_test.go new file mode 100644 index 00000000..f195ce12 --- /dev/null +++ b/tests/zz_private_endpoint_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package tests + +import ( + "net" + "testing" + "time" + + registryclient "github.com/pilot-protocol/common/registry/client" + "github.com/pilot-protocol/pilotprotocol/pkg/daemon" +) + +// privateInterfaceIP returns a private (RFC 1918) IPv4 address of this host +// on which the given TCP port is reachable, or "" when there is none. +func privateInterfaceIP(port string) string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, a := range addrs { + ipNet, ok := a.(*net.IPNet) + if !ok { + continue + } + ip := ipNet.IP.To4() + if ip == nil || !ip.IsPrivate() { + continue + } + c, err := net.DialTimeout("tcp", net.JoinHostPort(ip.String(), port), time.Second) + if err != nil { + continue + } + c.Close() + return ip.String() + } + return "" +} + +// TestPrivateNetworkEndpointReported covers a node whose beacon and registry +// are both on its own private network (a container on a Docker bridge, a lab +// LAN). STUN then reflects a private address, which the daemon discards, and +// the address it sends to the registry is the loopback form of its wildcard +// tunnel socket. The registry replaces that host with the one it observed, so +// peers resolve a reachable private address with the real tunnel port — and +// the daemon must report that same endpoint rather than loopback. +func TestPrivateNetworkEndpointReported(t *testing.T) { + requireRealNetwork(t) + t.Parallel() + env := NewTestEnv(t) + + _, regPort, _ := net.SplitHostPort(env.RegistryAddr) + _, beaconPort, _ := net.SplitHostPort(env.BeaconAddr) + lanIP := privateInterfaceIP(regPort) + if lanIP == "" { + t.Skip("no private IPv4 interface address on this host") + } + + a := env.AddDaemon(func(c *daemon.Config) { + c.RegistryAddr = net.JoinHostPort(lanIP, regPort) + c.BeaconAddr = net.JoinHostPort(lanIP, beaconPort) + }) + b := env.AddDaemon() + + rc, err := registryclient.Dial(env.RegistryAddr) + if err != nil { + t.Fatalf("dial registry: %v", err) + } + defer rc.Close() + setClientSigner(rc, b.Daemon.Identity()) + resp, err := rc.Resolve(a.Daemon.NodeID(), b.Daemon.NodeID()) + if err != nil { + t.Fatalf("resolve: %v", err) + } + realAddr, _ := resp["real_addr"].(string) + host, _, err := net.SplitHostPort(realAddr) + if err != nil { + t.Fatalf("real_addr %q: %v", realAddr, err) + } + if ip := net.ParseIP(host); ip == nil || ip.IsLoopback() || ip.IsUnspecified() { + t.Fatalf("registry hands peers an unusable endpoint %q", realAddr) + } + if host != lanIP { + t.Errorf("registry endpoint host = %s, want %s", host, lanIP) + } + + if got := a.Daemon.Info().Endpoint; got != realAddr { + t.Errorf("daemon reports endpoint %q, but peers resolve %q", got, realAddr) + } +}