Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<observed-ip>:<local
port>`. 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]:<port>` 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
Expand Down
16 changes: 16 additions & 0 deletions pkg/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions pkg/daemon/zz_reregister_advertise_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
90 changes: 90 additions & 0 deletions tests/zz_private_endpoint_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading