diff --git a/cmd/meshd/main.go b/cmd/meshd/main.go index 6b1928b..5eac2c6 100644 --- a/cmd/meshd/main.go +++ b/cmd/meshd/main.go @@ -1997,16 +1997,26 @@ func parseJoinCommandArgs(args []string) (inviteURL string, ownerDID string, noS return inviteURL, ownerDID, noStartHint, nil } +const peerListDaemonLookupTimeout = 750 * time.Millisecond + +type peerListCommandDependencies struct { + loadIdentity func(string) (*did.DID, error) + loadDaemonStatus func(context.Context, string) (*daemon.Status, error) +} + // cmdPeerList lists all peers in the current mesh network. func cmdPeerList(ctx context.Context, args []string, flagProfile string) error { + return cmdPeerListWithDependencies(ctx, args, flagProfile, peerListCommandDependencies{ + loadIdentity: loadIdentity, + loadDaemonStatus: loadPeerListDaemonStatus, + }) +} + +func cmdPeerListWithDependencies(ctx context.Context, args []string, flagProfile string, deps peerListCommandDependencies) error { stateDir, err := resolveStateDir(flagProfile) if err != nil { return err } - identity, err := loadIdentity(stateDir) - if err != nil { - return err - } ns, err := state.LoadNetworkState(stateDir) if err != nil { @@ -2015,6 +2025,31 @@ func cmdPeerList(ctx context.Context, args []string, flagProfile string) error { if ns == nil { return fmt.Errorf("not in a network. Use 'meshd network join' first.") } + + // The running daemon already owns a materialized, last-good view of the + // mesh. Prefer it before unlocking identity state or contacting the DWN. + // Legacy daemon responses and snapshots for another profile/network are + // deliberately ignored and fall through to the remote path below. + if deps.loadDaemonStatus != nil { + if status, statusErr := deps.loadDaemonStatus(ctx, daemon.DefaultSocketPath()); statusErr == nil { + if rows, warning, ok := peerListRowsFromDaemonStatus(ns, status); ok { + if warning != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", warning) + } + printPeerListRows(ns.NetworkName, rows) + return nil + } + } + } + + identityLoader := deps.loadIdentity + if identityLoader == nil { + identityLoader = loadIdentity + } + identity, err := identityLoader(stateDir) + if err != nil { + return err + } selfNodeDID := networkNodeDID(ns, identity.URI) meta := resolveIdentityMetadata(flagProfile, identity.URI) selfOwnerDID := networkOwnerDID(ns, firstNonEmpty(meta.OwnerDID, identity.URI)) @@ -2142,6 +2177,12 @@ func cmdPeerList(ctx context.Context, args []string, flagProfile string) error { return nil } +func loadPeerListDaemonStatus(ctx context.Context, socketPath string) (*daemon.Status, error) { + lookupCtx, cancel := context.WithTimeout(ctx, peerListDaemonLookupTimeout) + defer cancel() + return daemon.NewClient(socketPath).GetStatus(lookupCtx) +} + func loadControlStateForCLI(ctx context.Context, ns *state.NetworkState, identity *did.DID, signerIdentity *did.DID, encMgr *dwncrypto.EncryptionKeyManager, readAuth dwn.MessageAuth, delegateSession *mesh.DelegateSession) (*control.MapResponse, error) { if ns == nil || identity == nil { return nil, fmt.Errorf("network state and identity are required") @@ -2267,6 +2308,76 @@ func peerListRowsFromMapResponse(ns *state.NetworkState, resp *control.MapRespon return rows } +func peerListRowsFromDaemonStatus(ns *state.NetworkState, status *daemon.Status) ([]peerListRow, string, bool) { + if ns == nil || status == nil || !status.Running { + return nil, "", false + } + + // NodeDID was added to network.json after the first releases. Without it + // there is no identity-free way to prove that the daemon snapshot belongs + // to this profile, so legacy state must use the existing identity path. + selfNodeDID := strings.TrimSpace(ns.NodeDID) + if selfNodeDID == "" || strings.TrimSpace(ns.NetworkRecordID) == "" { + return nil, "", false + } + if status.NetworkRecordID != ns.NetworkRecordID || status.Self == nil || status.Self.NodeDID != selfNodeDID { + return nil, "", false + } + + snapshot := status.Snapshot + if snapshot == nil || snapshot.Generation == 0 || strings.TrimSpace(snapshot.RefreshedAt) == "" { + return nil, "", false + } + if _, err := time.Parse(time.RFC3339Nano, snapshot.RefreshedAt); err != nil { + return nil, "", false + } + + selfOwnerDID := firstNonEmpty(status.OwnerDID, ns.EffectiveOwnerDID(selfNodeDID)) + peers := make([]daemon.PeerStatus, 0, 1+len(status.Peers)) + peers = append(peers, *status.Self) + peers = append(peers, status.Peers...) + + rows := make([]peerListRow, 0, len(peers)) + seen := make(map[string]struct{}, len(peers)) + for _, peer := range peers { + nodeDID := strings.TrimSpace(peer.NodeDID) + if nodeDID == "" { + continue + } + if _, duplicate := seen[nodeDID]; duplicate { + continue + } + seen[nodeDID] = struct{}{} + + path := "network/node" + if peer.MemberRecordID != "" { + path = "network/member/node" + } + rows = append(rows, peerListRow{ + NodeDID: nodeDID, + MeshIP: peerListMeshIP(ns.MeshCIDR, nodeDID, peer.MeshIP), + Device: peerListDevice(nodeDID, selfNodeDID), + Owner: peerListOwner(nodeDID, peer.OwnerDID, selfNodeDID, selfOwnerDID), + Label: firstNonEmpty(peer.Label, peer.Name), + Expires: peer.ExpiresAt, + Path: path, + }) + } + if len(rows) == 0 || rows[0].NodeDID != selfNodeDID { + return nil, "", false + } + + warning := "" + if lastError := strings.TrimSpace(snapshot.LastError); lastError != "" { + warning = fmt.Sprintf( + "showing last known peer snapshot from %s; latest refresh failed: %s", + snapshot.RefreshedAt, + lastError, + ) + } + return rows, warning, true +} + func printPeerListRows(networkName string, rows []peerListRow) { if len(rows) == 0 { fmt.Println("No peers found.") @@ -4137,13 +4248,23 @@ func cmdUp(ctx context.Context, args []string, flagProfile string) error { // ── Step 4: Start the daemon control socket ──────────────────── daemonSrv := daemon.NewServer(socketPath, func() daemon.Status { routing := eng.RoutingStatus() + meshSnapshot := eng.MeshSnapshot() + selfStatus, peerStatuses, snapshotStatus := daemonStatusesFromMeshSnapshot(meshSnapshot) + if meshSnapshot == nil { + // Preserve the legacy tray/status view before the first materialized + // snapshot attempt. The peer-list fast path requires Snapshot+Self + // and will correctly fall back while this compatibility view is used. + peerStatuses = daemonPeerStatuses(eng.PeerSnapshots()) + } return daemon.Status{ TUNDevice: eng.TUNDeviceName(), MeshIP: ns.MeshIP, Network: ns.NetworkName, OwnerDID: networkOwnerDID(ns, meta.OwnerDID), NetworkRecordID: ns.NetworkRecordID, - Peers: daemonPeerStatuses(eng.PeerSnapshots()), + Self: selfStatus, + Peers: peerStatuses, + Snapshot: snapshotStatus, RoutingRequired: routing.Required, RoutingReady: routing.Ready, RoutingPhase: routing.Phase, @@ -4200,21 +4321,60 @@ func cmdUp(ctx context.Context, args []string, flagProfile string) error { return nil } +func daemonStatusesFromMeshSnapshot(snapshot *engine.MeshSnapshot) (*daemon.PeerStatus, []daemon.PeerStatus, *daemon.SnapshotStatus) { + if snapshot == nil { + return nil, nil, nil + } + + var self *daemon.PeerStatus + if snapshot.Self != nil { + status := daemonPeerStatus(*snapshot.Self) + self = &status + } + return self, daemonPeerStatuses(snapshot.Peers), &daemon.SnapshotStatus{ + Generation: snapshot.Generation, + RefreshedAt: daemonSnapshotTimestamp(snapshot.RefreshedAt), + LastAttemptAt: daemonSnapshotTimestamp(snapshot.LastAttemptAt), + LastError: snapshot.LastError, + } +} + func daemonPeerStatuses(peers []engine.PeerSnapshot) []daemon.PeerStatus { if len(peers) == 0 { return nil } status := make([]daemon.PeerStatus, 0, len(peers)) for _, peer := range peers { - status = append(status, daemon.PeerStatus{ - Name: peer.Name, - MeshIP: peer.MeshIP, - Online: peer.Online, - }) + status = append(status, daemonPeerStatus(peer)) } return status } +func daemonPeerStatus(peer engine.PeerSnapshot) daemon.PeerStatus { + lastSeen := "" + if peer.LastSeen != nil { + lastSeen = daemonSnapshotTimestamp(*peer.LastSeen) + } + return daemon.PeerStatus{ + NodeDID: peer.NodeDID, + Name: peer.Name, + MeshIP: peer.MeshIP, + OwnerDID: peer.OwnerDID, + MemberRecordID: peer.MemberRecordID, + Label: peer.Label, + ExpiresAt: peer.ExpiresAt, + Online: peer.Online, + LastSeen: lastSeen, + } +} + +func daemonSnapshotTimestamp(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339Nano) +} + // ensureIdentity creates a new identity profile when none exists. // It's called by cmdUp when no identity is found. func ensureIdentity(ctx context.Context, flagProfile, endpoint string) (*did.DID, error) { diff --git a/cmd/meshd/peer_list_local_test.go b/cmd/meshd/peer_list_local_test.go new file mode 100644 index 0000000..e61838d --- /dev/null +++ b/cmd/meshd/peer_list_local_test.go @@ -0,0 +1,346 @@ +package main + +import ( + "context" + "errors" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/enboxorg/meshd/internal/daemon" + "github.com/enboxorg/meshd/internal/did" + "github.com/enboxorg/meshd/internal/engine" + "github.com/enboxorg/meshd/internal/state" +) + +func TestPeerListRowsFromDaemonStatus(t *testing.T) { + refreshedAt := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339Nano) + ns := &state.NetworkState{ + NetworkRecordID: "network-1", + NetworkName: "home", + MeshCIDR: "10.200.0.0/16", + NodeDID: "did:jwk:self", + OwnerDID: "did:jwk:wallet", + } + status := &daemon.Status{ + Running: true, + NetworkRecordID: "network-1", + OwnerDID: "did:jwk:wallet", + Snapshot: &daemon.SnapshotStatus{ + Generation: 7, + RefreshedAt: refreshedAt, + LastError: "remote refresh timed out", + }, + Self: &daemon.PeerStatus{ + NodeDID: "did:jwk:self", + OwnerDID: "did:jwk:wallet", + MemberRecordID: "member-self", + Name: "laptop-host", + Label: "macbook", + MeshIP: "10.200.0.5", + ExpiresAt: "2026-08-01T00:00:00Z", + Online: true, + }, + Peers: []daemon.PeerStatus{ + { + NodeDID: "did:jwk:peer", + OwnerDID: "did:jwk:peer-owner", + Name: "server-host", + Label: "server", + MeshIP: "10.200.0.8", + ExpiresAt: "2026-08-02T00:00:00Z", + }, + // Defensive duplicate suppression keeps self first even if a + // transitional daemon accidentally includes it in Peers. + {NodeDID: "did:jwk:self", MeshIP: "10.200.0.99"}, + }, + } + + rows, warning, ok := peerListRowsFromDaemonStatus(ns, status) + if !ok { + t.Fatal("peerListRowsFromDaemonStatus rejected ready matching snapshot") + } + want := []peerListRow{ + { + NodeDID: "did:jwk:self", + MeshIP: "10.200.0.5", + Device: "this device", + Owner: "did:jwk:wallet", + Label: "macbook", + Expires: "2026-08-01T00:00:00Z", + Path: "network/member/node", + }, + { + NodeDID: "did:jwk:peer", + MeshIP: "10.200.0.8", + Device: "peer", + Owner: "did:jwk:peer-owner", + Label: "server", + Expires: "2026-08-02T00:00:00Z", + Path: "network/node", + }, + } + if !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %#v, want %#v", rows, want) + } + if !strings.Contains(warning, refreshedAt) || !strings.Contains(warning, "remote refresh timed out") { + t.Fatalf("warning = %q, want last-good timestamp and refresh error", warning) + } +} + +func TestPeerListRowsFromDaemonStatusRejectsUntrustedOrUnreadySnapshot(t *testing.T) { + readyStatus := func() *daemon.Status { + return &daemon.Status{ + Running: true, + NetworkRecordID: "network-1", + Self: &daemon.PeerStatus{NodeDID: "did:jwk:self", MeshIP: "10.200.0.5"}, + Snapshot: &daemon.SnapshotStatus{ + Generation: 1, + RefreshedAt: "2026-07-11T12:00:00.123456789Z", + }, + } + } + readyState := func() *state.NetworkState { + return &state.NetworkState{ + NetworkRecordID: "network-1", + MeshCIDR: "10.200.0.0/16", + NodeDID: "did:jwk:self", + } + } + + tests := []struct { + name string + mutate func(*state.NetworkState, *daemon.Status) (*state.NetworkState, *daemon.Status) + }{ + { + name: "absent daemon status", + mutate: func(ns *state.NetworkState, _ *daemon.Status) (*state.NetworkState, *daemon.Status) { + return ns, nil + }, + }, + { + name: "old daemon response", + mutate: func(ns *state.NetworkState, status *daemon.Status) (*state.NetworkState, *daemon.Status) { + status.Self = nil + status.Snapshot = nil + return ns, status + }, + }, + { + name: "legacy state without node DID", + mutate: func(ns *state.NetworkState, status *daemon.Status) (*state.NetworkState, *daemon.Status) { + ns.NodeDID = "" + return ns, status + }, + }, + { + name: "network mismatch", + mutate: func(ns *state.NetworkState, status *daemon.Status) (*state.NetworkState, *daemon.Status) { + status.NetworkRecordID = "other-network" + return ns, status + }, + }, + { + name: "self mismatch", + mutate: func(ns *state.NetworkState, status *daemon.Status) (*state.NetworkState, *daemon.Status) { + status.Self.NodeDID = "did:jwk:other-profile" + return ns, status + }, + }, + { + name: "zero generation", + mutate: func(ns *state.NetworkState, status *daemon.Status) (*state.NetworkState, *daemon.Status) { + status.Snapshot.Generation = 0 + return ns, status + }, + }, + { + name: "missing refresh time", + mutate: func(ns *state.NetworkState, status *daemon.Status) (*state.NetworkState, *daemon.Status) { + status.Snapshot.RefreshedAt = "" + return ns, status + }, + }, + { + name: "malformed refresh time", + mutate: func(ns *state.NetworkState, status *daemon.Status) (*state.NetworkState, *daemon.Status) { + status.Snapshot.RefreshedAt = "not-a-time" + return ns, status + }, + }, + { + name: "not running", + mutate: func(ns *state.NetworkState, status *daemon.Status) (*state.NetworkState, *daemon.Status) { + status.Running = false + return ns, status + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ns, status := tc.mutate(readyState(), readyStatus()) + if rows, warning, ok := peerListRowsFromDaemonStatus(ns, status); ok || rows != nil || warning != "" { + t.Fatalf("result = (%+v, %q, %v), want remote fallback", rows, warning, ok) + } + }) + } +} + +func TestDaemonStatusesFromMeshSnapshot(t *testing.T) { + refreshedAt := time.Date(2026, 7, 11, 12, 0, 0, 123456789, time.FixedZone("offset", -7*60*60)) + lastAttemptAt := refreshedAt.Add(2 * time.Minute) + lastSeen := refreshedAt.Add(-time.Minute) + snapshot := &engine.MeshSnapshot{ + Generation: 9, + RefreshedAt: refreshedAt, + LastAttemptAt: lastAttemptAt, + LastError: "latest refresh failed", + Self: &engine.PeerSnapshot{ + NodeDID: "did:jwk:self", + Name: "laptop-host", + MeshIP: "10.200.0.5", + OwnerDID: "did:jwk:wallet", + MemberRecordID: "member-self", + Label: "laptop", + ExpiresAt: "2026-08-01T00:00:00Z", + Online: true, + LastSeen: &lastSeen, + }, + Peers: []engine.PeerSnapshot{{ + NodeDID: "did:jwk:peer", + Name: "server-host", + MeshIP: "10.200.0.8", + OwnerDID: "did:jwk:peer-owner", + Label: "server", + ExpiresAt: "2026-08-02T00:00:00Z", + }}, + } + + self, peers, freshness := daemonStatusesFromMeshSnapshot(snapshot) + if self == nil { + t.Fatal("self status = nil") + } + if self.NodeDID != "did:jwk:self" || self.OwnerDID != "did:jwk:wallet" || + self.MemberRecordID != "member-self" || self.Label != "laptop" || + self.ExpiresAt != "2026-08-01T00:00:00Z" || !self.Online || + self.LastSeen != lastSeen.UTC().Format(time.RFC3339Nano) { + t.Fatalf("self status = %+v", self) + } + if len(peers) != 1 || peers[0].NodeDID != "did:jwk:peer" || + peers[0].OwnerDID != "did:jwk:peer-owner" || peers[0].Label != "server" { + t.Fatalf("peer statuses = %+v", peers) + } + if freshness == nil || freshness.Generation != 9 || + freshness.RefreshedAt != refreshedAt.UTC().Format(time.RFC3339Nano) || + freshness.LastAttemptAt != lastAttemptAt.UTC().Format(time.RFC3339Nano) || + freshness.LastError != "latest refresh failed" { + t.Fatalf("freshness = %+v", freshness) + } + + if self, peers, freshness := daemonStatusesFromMeshSnapshot(nil); self != nil || peers != nil || freshness != nil { + t.Fatalf("nil snapshot = (%+v, %+v, %+v), want nils", self, peers, freshness) + } +} + +func TestCmdPeerListUsesDaemonSnapshotBeforeIdentity(t *testing.T) { + stateDir := t.TempDir() + t.Setenv("MESHD_STATE_DIR", stateDir) + ns := &state.NetworkState{ + NetworkRecordID: "network-1", + NetworkName: "home", + MeshCIDR: "10.200.0.0/16", + NodeDID: "did:jwk:self", + OwnerDID: "did:jwk:wallet", + } + if err := state.SaveNetworkState(stateDir, ns); err != nil { + t.Fatalf("SaveNetworkState: %v", err) + } + + var identityLoads atomic.Int32 + var statusLoads atomic.Int32 + output, err := captureStdout(t, func() error { + return cmdPeerListWithDependencies(context.Background(), nil, "", peerListCommandDependencies{ + loadIdentity: func(string) (*did.DID, error) { + identityLoads.Add(1) + return nil, errors.New("identity must not be loaded") + }, + loadDaemonStatus: func(context.Context, string) (*daemon.Status, error) { + statusLoads.Add(1) + return &daemon.Status{ + Running: true, + NetworkRecordID: "network-1", + Self: &daemon.PeerStatus{ + NodeDID: "did:jwk:self", + OwnerDID: "did:jwk:wallet", + Label: "laptop", + MeshIP: "10.200.0.5", + }, + Peers: []daemon.PeerStatus{{ + NodeDID: "did:jwk:peer", + Label: "server", + MeshIP: "10.200.0.8", + }}, + Snapshot: &daemon.SnapshotStatus{ + Generation: 1, + RefreshedAt: time.Now().UTC().Format(time.RFC3339Nano), + }, + }, nil + }, + }) + }) + if err != nil { + t.Fatalf("cmdPeerListWithDependencies: %v", err) + } + if got := identityLoads.Load(); got != 0 { + t.Fatalf("identity loads = %d, want zero", got) + } + if got := statusLoads.Load(); got != 1 { + t.Fatalf("daemon status loads = %d, want one", got) + } + for _, want := range []string{"Peers in \"home\"", "did:jwk:self", "this device", "did:jwk:peer", "server"} { + if !strings.Contains(output, want) { + t.Fatalf("output missing %q:\n%s", want, output) + } + } +} + +func TestCmdPeerListFallsBackToIdentityForMismatchedSnapshot(t *testing.T) { + stateDir := t.TempDir() + t.Setenv("MESHD_STATE_DIR", stateDir) + if err := state.SaveNetworkState(stateDir, &state.NetworkState{ + NetworkRecordID: "network-1", + NetworkName: "home", + NodeDID: "did:jwk:self", + }); err != nil { + t.Fatalf("SaveNetworkState: %v", err) + } + + wantErr := errors.New("identity fallback reached") + var identityLoads atomic.Int32 + err := cmdPeerListWithDependencies(context.Background(), nil, "", peerListCommandDependencies{ + loadIdentity: func(string) (*did.DID, error) { + identityLoads.Add(1) + return nil, wantErr + }, + loadDaemonStatus: func(context.Context, string) (*daemon.Status, error) { + return &daemon.Status{ + Running: true, + NetworkRecordID: "other-network", + Self: &daemon.PeerStatus{NodeDID: "did:jwk:self"}, + Snapshot: &daemon.SnapshotStatus{ + Generation: 1, + RefreshedAt: time.Now().UTC().Format(time.RFC3339Nano), + }, + }, nil + }, + }) + if !errors.Is(err, wantErr) { + t.Fatalf("cmdPeerListWithDependencies error = %v, want identity fallback", err) + } + if got := identityLoads.Load(); got != 1 { + t.Fatalf("identity loads = %d, want one", got) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 022274b..ea1ab58 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -49,28 +49,45 @@ func DefaultSocketPath() string { // Status is the JSON response from GET /api/v0/status. type Status struct { - Running bool `json:"running"` - InstanceID string `json:"instanceID,omitempty"` - TUNDevice string `json:"tunDevice,omitempty"` - MeshIP string `json:"meshIP,omitempty"` - Network string `json:"network,omitempty"` - OwnerDID string `json:"ownerDID,omitempty"` - NetworkRecordID string `json:"networkRecordID,omitempty"` - Peers []PeerStatus `json:"peers,omitempty"` - RoutingRequired bool `json:"routingRequired"` - RoutingReady bool `json:"routingReady"` - RoutingPhase string `json:"routingPhase,omitempty"` - RoutingError string `json:"routingError,omitempty"` - Uptime string `json:"uptime,omitempty"` - PID int `json:"pid"` + Running bool `json:"running"` + InstanceID string `json:"instanceID,omitempty"` + TUNDevice string `json:"tunDevice,omitempty"` + MeshIP string `json:"meshIP,omitempty"` + Network string `json:"network,omitempty"` + OwnerDID string `json:"ownerDID,omitempty"` + NetworkRecordID string `json:"networkRecordID,omitempty"` + Peers []PeerStatus `json:"peers,omitempty"` + Self *PeerStatus `json:"self,omitempty"` + Snapshot *SnapshotStatus `json:"snapshot,omitempty"` + RoutingRequired bool `json:"routingRequired"` + RoutingReady bool `json:"routingReady"` + RoutingPhase string `json:"routingPhase,omitempty"` + RoutingError string `json:"routingError,omitempty"` + Uptime string `json:"uptime,omitempty"` + PID int `json:"pid"` } // PeerStatus is the status-facing view of a peer from the engine's latest // network map. type PeerStatus struct { - Name string `json:"name"` - MeshIP string `json:"meshIP"` - Online bool `json:"online"` + Name string `json:"name"` + MeshIP string `json:"meshIP"` + Online bool `json:"online"` + NodeDID string `json:"nodeDID,omitempty"` + OwnerDID string `json:"ownerDID,omitempty"` + MemberRecordID string `json:"memberRecordID,omitempty"` + Label string `json:"label,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + LastSeen string `json:"lastSeen,omitempty"` +} + +// SnapshotStatus describes the freshness of the daemon's materialized mesh +// snapshot. Timestamp fields are encoded as RFC3339Nano strings. +type SnapshotStatus struct { + Generation uint64 `json:"generation"` + RefreshedAt string `json:"refreshedAt,omitempty"` + LastAttemptAt string `json:"lastAttemptAt,omitempty"` + LastError string `json:"lastError,omitempty"` } type peerAuthorizedContextKey struct{} diff --git a/internal/daemon/status_snapshot_test.go b/internal/daemon/status_snapshot_test.go new file mode 100644 index 0000000..6da6085 --- /dev/null +++ b/internal/daemon/status_snapshot_test.go @@ -0,0 +1,126 @@ +package daemon + +import ( + "context" + "encoding/json" + "net" + "net/http" + "testing" + "time" +) + +func TestStatusEndpointRichSnapshot(t *testing.T) { + sock := testSocketPath(t) + wantPeer := PeerStatus{ + Name: "peer-a", + MeshIP: "10.200.0.2", + Online: true, + NodeDID: "did:example:peer-a", + OwnerDID: "did:example:peer-owner", + MemberRecordID: "member-peer-a", + Label: "build-server", + ExpiresAt: "2026-07-18T02:34:11.140123456Z", + LastSeen: "2026-07-11T17:40:05.123456789Z", + } + wantSelf := PeerStatus{ + Name: "this device", + MeshIP: "10.200.0.1", + Online: true, + NodeDID: "did:example:self", + OwnerDID: "did:example:self-owner", + MemberRecordID: "member-self", + Label: "laptop", + ExpiresAt: "2026-07-18T02:34:11.140123456Z", + LastSeen: "2026-07-11T17:40:06.987654321Z", + } + wantSnapshot := SnapshotStatus{ + Generation: 42, + RefreshedAt: "2026-07-11T17:40:06.987654321Z", + LastAttemptAt: "2026-07-11T17:40:07.123456789Z", + LastError: "refresh deferred by rate limit", + } + + srv := NewServer(sock, func() Status { + return Status{ + MeshIP: wantSelf.MeshIP, + Network: "test-net", + NetworkRecordID: "network-1", + Peers: []PeerStatus{wantPeer}, + Self: &wantSelf, + Snapshot: &wantSnapshot, + } + }, nil) + if err := srv.Start(); err != nil { + t.Fatalf("Start() error: %v", err) + } + defer srv.Stop() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + status, err := NewClient(sock).GetStatus(ctx) + if err != nil { + t.Fatalf("GetStatus() error: %v", err) + } + + if len(status.Peers) != 1 || status.Peers[0] != wantPeer { + t.Fatalf("Peers = %+v, want %+v", status.Peers, []PeerStatus{wantPeer}) + } + if status.Self == nil || *status.Self != wantSelf { + t.Fatalf("Self = %+v, want %+v", status.Self, wantSelf) + } + if status.Snapshot == nil || *status.Snapshot != wantSnapshot { + t.Fatalf("Snapshot = %+v, want %+v", status.Snapshot, wantSnapshot) + } +} + +func TestClientGetStatusLegacyResponse(t *testing.T) { + sock := testSocketPath(t) + listener, err := net.Listen("unix", sock) + if err != nil { + t.Fatalf("Listen() error: %v", err) + } + legacyJSON := []byte(`{ + "running": true, + "meshIP": "10.200.0.1", + "network": "test-net", + "networkRecordID": "network-1", + "peers": [{"name":"peer-a","meshIP":"10.200.0.2","online":true}], + "routingRequired": false, + "routingReady": false, + "pid": 123 + }`) + httpServer := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(legacyJSON) + })} + go func() { + _ = httpServer.Serve(listener) + }() + defer httpServer.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + status, err := NewClient(sock).GetStatus(ctx) + if err != nil { + t.Fatalf("GetStatus() legacy response error: %v", err) + } + if status.Self != nil || status.Snapshot != nil { + t.Fatalf("legacy response additions = self %+v, snapshot %+v; want nil", status.Self, status.Snapshot) + } + wantPeer := PeerStatus{Name: "peer-a", MeshIP: "10.200.0.2", Online: true} + if len(status.Peers) != 1 || status.Peers[0] != wantPeer { + t.Fatalf("legacy Peers = %+v, want %+v", status.Peers, []PeerStatus{wantPeer}) + } +} + +func TestPeerStatusLegacyJSONShape(t *testing.T) { + peer := PeerStatus{Name: "peer-a", MeshIP: "10.200.0.2", Online: true} + got, err := json.Marshal(peer) + if err != nil { + t.Fatalf("Marshal() error: %v", err) + } + want := `{"name":"peer-a","meshIP":"10.200.0.2","online":true}` + if string(got) != want { + t.Fatalf("legacy PeerStatus JSON = %s, want %s", got, want) + } +} diff --git a/internal/engine/convert.go b/internal/engine/convert.go index 8c36f6f..528c543 100644 --- a/internal/engine/convert.go +++ b/internal/engine/convert.go @@ -422,12 +422,28 @@ func (c *Converter) convertFilterRules(rules []control.FilterRule) []tailcfg.Fil // This function is the bridge between meshd's DWN control client and // meshnet's DWNControl polling loop. It is passed to DWNControlConfig.MapResponseFunc. func MapResponseFunc(client *control.DWNClient, converter *Converter) func(context.Context) (*netmap.NetworkMap, error) { + return mapResponseFunc(client.LoadState, converter, nil) +} + +func mapResponseFunc( + load func(context.Context) (*control.MapResponse, error), + converter *Converter, + onResult func(*control.MapResponse, error), +) func(context.Context) (*netmap.NetworkMap, error) { return func(ctx context.Context) (*netmap.NetworkMap, error) { - resp, err := client.LoadState(ctx) + resp, err := load(ctx) if err != nil { - return nil, fmt.Errorf("loading DWN state: %w", err) + err = fmt.Errorf("loading DWN state: %w", err) + if onResult != nil { + onResult(nil, err) + } + return nil, err } - return converter.Convert(resp) + nm, err := converter.Convert(resp) + if onResult != nil { + onResult(resp, err) + } + return nm, err } } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 364746b..ae58443 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -70,6 +70,8 @@ type Engine struct { routingMu sync.RWMutex routing routingState + + snapshots *meshSnapshotStore } // Config holds the configuration for creating an Engine. @@ -451,7 +453,8 @@ func New(cfg Config) (*Engine, error) { // Wire the DWN control client into the LocalBackend. // MapResponseFunc closes over our DWNClient and Converter to produce // NetworkMaps from DWN records. - mapFn := MapResponseFunc(dwnClient, converter) + snapshots := &meshSnapshotStore{} + mapFn := mapResponseFunc(dwnClient.LoadState, converter, snapshots.record) var engineRef *Engine dwnControlConfig := &DWNControlConfig{ MapResponseFunc: mapFn, @@ -500,6 +503,7 @@ func New(cfg Config) (*Engine, error) { tunName: tunName, osRouter: osRouter, logger: l, + snapshots: snapshots, } engineRef.initializeRoutingStatus(cfg.TUNName != "") return engineRef, nil diff --git a/internal/engine/peers.go b/internal/engine/peers.go index 96c7833..e266981 100644 --- a/internal/engine/peers.go +++ b/internal/engine/peers.go @@ -8,20 +8,32 @@ import ( "github.com/enboxorg/meshnet/types/netmap" ) -// PeerSnapshot is the tray- and status-facing view of a peer in the latest -// network map. MeshIP is the first valid address assigned directly to the -// peer. LastSeen is nil when the control plane has never observed the peer. +// PeerSnapshot is the tray- and status-facing view of a node in the latest +// successful control-plane snapshot. MeshIP is the node's assigned mesh +// address. LastSeen is nil when the control plane has never observed it. type PeerSnapshot struct { - Name string - MeshIP string - Online bool - LastSeen *time.Time + NodeDID string + Name string + MeshIP string + OwnerDID string + MemberRecordID string + Label string + ExpiresAt string + Online bool + LastSeen *time.Time } -// PeerSnapshots returns peers from the latest network map received by the -// engine. It returns nil until a network map is available. +// PeerSnapshots returns peers from the latest successful control-plane +// snapshot. Before the first materialized snapshot is available, it falls back +// to the latest network map for compatibility. func (e *Engine) PeerSnapshots() []PeerSnapshot { - if e == nil || e.backend == nil { + if e == nil { + return nil + } + if snapshot := e.MeshSnapshot(); snapshot != nil && snapshot.Generation > 0 { + return snapshot.Peers + } + if e.backend == nil { return nil } return peerSnapshotsFromNetMap(e.backend.NetMap()) diff --git a/internal/engine/snapshot.go b/internal/engine/snapshot.go new file mode 100644 index 0000000..a4c5191 --- /dev/null +++ b/internal/engine/snapshot.go @@ -0,0 +1,147 @@ +package engine + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/enboxorg/meshd/internal/control" +) + +// MeshSnapshot is the daemon-facing, materialized view of the latest complete +// control-plane state. A failed refresh updates LastAttemptAt and LastError but +// preserves the last successful generation, RefreshedAt, Self, and Peers. +type MeshSnapshot struct { + Generation uint64 + RefreshedAt time.Time + LastAttemptAt time.Time + LastError string + Self *PeerSnapshot + Peers []PeerSnapshot +} + +// meshSnapshotStore serializes publishers and atomically exposes immutable +// snapshots to readers. Values stored in current are never mutated. +type meshSnapshotStore struct { + mu sync.Mutex + current atomic.Pointer[MeshSnapshot] +} + +// MeshSnapshot returns a deep copy of the engine's current materialized view. +// It returns nil before the first refresh attempt. +func (e *Engine) MeshSnapshot() *MeshSnapshot { + if e == nil || e.snapshots == nil { + return nil + } + return e.snapshots.load() +} + +func (s *meshSnapshotStore) load() *MeshSnapshot { + if s == nil { + return nil + } + return cloneMeshSnapshot(s.current.Load()) +} + +func (s *meshSnapshotStore) record(resp *control.MapResponse, err error) { + if s == nil { + return + } + now := time.Now().UTC() + + s.mu.Lock() + defer s.mu.Unlock() + + previous := s.current.Load() + if err != nil { + next := cloneMeshSnapshot(previous) + if next == nil { + next = &MeshSnapshot{} + } + next.LastAttemptAt = now + next.LastError = err.Error() + s.current.Store(next) + return + } + + generation := uint64(1) + if previous != nil { + generation = previous.Generation + 1 + } + s.current.Store(meshSnapshotFromMapResponse(resp, generation, now)) +} + +func meshSnapshotFromMapResponse(resp *control.MapResponse, generation uint64, refreshedAt time.Time) *MeshSnapshot { + snapshot := &MeshSnapshot{ + Generation: generation, + RefreshedAt: refreshedAt, + LastAttemptAt: refreshedAt, + } + if resp == nil { + return snapshot + } + if resp.Node != nil { + self := peerSnapshotFromControlNode(resp.Node) + snapshot.Self = &self + } + if len(resp.Peers) > 0 { + snapshot.Peers = make([]PeerSnapshot, 0, len(resp.Peers)) + for _, peer := range resp.Peers { + if peer == nil { + continue + } + snapshot.Peers = append(snapshot.Peers, peerSnapshotFromControlNode(peer)) + } + if len(snapshot.Peers) == 0 { + snapshot.Peers = nil + } + } + return snapshot +} + +func peerSnapshotFromControlNode(node *control.Node) PeerSnapshot { + snapshot := PeerSnapshot{ + NodeDID: node.DID, + Name: node.Name, + OwnerDID: node.MemberDID, + MemberRecordID: node.MemberRecordID, + Label: node.Label, + ExpiresAt: node.ExpiresAt, + Online: node.Online, + } + if node.MeshIP.IsValid() { + snapshot.MeshIP = node.MeshIP.String() + } + if !node.LastSeen.IsZero() { + lastSeen := node.LastSeen + snapshot.LastSeen = &lastSeen + } + return snapshot +} + +func cloneMeshSnapshot(snapshot *MeshSnapshot) *MeshSnapshot { + if snapshot == nil { + return nil + } + clone := *snapshot + if snapshot.Self != nil { + self := clonePeerSnapshot(*snapshot.Self) + clone.Self = &self + } + if snapshot.Peers != nil { + clone.Peers = make([]PeerSnapshot, len(snapshot.Peers)) + for i := range snapshot.Peers { + clone.Peers[i] = clonePeerSnapshot(snapshot.Peers[i]) + } + } + return &clone +} + +func clonePeerSnapshot(snapshot PeerSnapshot) PeerSnapshot { + clone := snapshot + if snapshot.LastSeen != nil { + lastSeen := *snapshot.LastSeen + clone.LastSeen = &lastSeen + } + return clone +} diff --git a/internal/engine/snapshot_test.go b/internal/engine/snapshot_test.go new file mode 100644 index 0000000..f73377f --- /dev/null +++ b/internal/engine/snapshot_test.go @@ -0,0 +1,317 @@ +package engine + +import ( + "context" + "errors" + "net/netip" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/enboxorg/meshd/internal/control" +) + +func TestMeshSnapshotStorePublishesRichSnapshotAndDeepCopies(t *testing.T) { + lastSeen := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC) + resp := &control.MapResponse{ + Node: &control.Node{ + DID: "did:example:self", + Name: "laptop", + Label: "My Laptop", + MemberDID: "did:example:owner", + MemberRecordID: "member-self", + ExpiresAt: "2026-08-01T00:00:00Z", + MeshIP: netip.MustParseAddr("10.200.0.1"), + Online: true, + LastSeen: lastSeen, + }, + Peers: []*control.Node{ + { + DID: "did:example:peer", + Name: "server", + Label: "Home Server", + MemberDID: "did:example:member", + MemberRecordID: "member-peer", + ExpiresAt: "2026-09-01T00:00:00Z", + MeshIP: netip.MustParseAddr("10.200.0.2"), + Online: true, + LastSeen: lastSeen.Add(-time.Minute), + }, + nil, + }, + } + store := &meshSnapshotStore{} + before := time.Now().UTC() + store.record(resp, nil) + after := time.Now().UTC() + + got := store.load() + if got == nil { + t.Fatal("snapshot is nil") + } + if got.Generation != 1 { + t.Fatalf("Generation = %d, want 1", got.Generation) + } + if got.RefreshedAt.Before(before) || got.RefreshedAt.After(after) { + t.Fatalf("RefreshedAt = %v, want between %v and %v", got.RefreshedAt, before, after) + } + if !got.LastAttemptAt.Equal(got.RefreshedAt) { + t.Fatalf("LastAttemptAt = %v, want RefreshedAt %v", got.LastAttemptAt, got.RefreshedAt) + } + if got.LastError != "" { + t.Fatalf("LastError = %q, want empty", got.LastError) + } + if got.Self == nil { + t.Fatal("Self is nil") + } + wantSelf := PeerSnapshot{ + NodeDID: "did:example:self", + Name: "laptop", + MeshIP: "10.200.0.1", + OwnerDID: "did:example:owner", + MemberRecordID: "member-self", + Label: "My Laptop", + ExpiresAt: "2026-08-01T00:00:00Z", + Online: true, + LastSeen: &lastSeen, + } + if !reflect.DeepEqual(*got.Self, wantSelf) { + t.Fatalf("Self = %#v, want %#v", *got.Self, wantSelf) + } + if len(got.Peers) != 1 { + t.Fatalf("Peers length = %d, want 1", len(got.Peers)) + } + if got.Peers[0].NodeDID != "did:example:peer" || + got.Peers[0].OwnerDID != "did:example:member" || + got.Peers[0].MemberRecordID != "member-peer" || + got.Peers[0].Label != "Home Server" || + got.Peers[0].ExpiresAt != "2026-09-01T00:00:00Z" || + got.Peers[0].MeshIP != "10.200.0.2" || + !got.Peers[0].Online { + t.Fatalf("peer = %#v", got.Peers[0]) + } + + // Neither the source MapResponse nor a returned snapshot may alias the + // immutable value retained by the store. + resp.Node.Label = "mutated source" + resp.Peers[0].Label = "mutated source peer" + got.Self.Label = "mutated result" + got.Peers[0].Label = "mutated result peer" + *got.Self.LastSeen = time.Time{} + *got.Peers[0].LastSeen = time.Time{} + + again := store.load() + if again.Self.Label != "My Laptop" || again.Peers[0].Label != "Home Server" { + t.Fatalf("stored labels were mutated: Self=%q Peer=%q", again.Self.Label, again.Peers[0].Label) + } + if again.Self.LastSeen == nil || !again.Self.LastSeen.Equal(lastSeen) { + t.Fatalf("stored self LastSeen = %v, want %v", again.Self.LastSeen, lastSeen) + } + if again.Peers[0].LastSeen == nil || !again.Peers[0].LastSeen.Equal(lastSeen.Add(-time.Minute)) { + t.Fatalf("stored peer LastSeen = %v", again.Peers[0].LastSeen) + } +} + +func TestMeshSnapshotStoreFailurePreservesLastGood(t *testing.T) { + store := &meshSnapshotStore{} + first := &control.MapResponse{ + Node: &control.Node{ + DID: "did:example:self", + Label: "first", + MeshIP: netip.MustParseAddr("10.200.0.1"), + }, + Peers: []*control.Node{{ + DID: "did:example:peer", + Label: "peer-first", + MeshIP: netip.MustParseAddr("10.200.0.2"), + }}, + } + store.record(first, nil) + success := store.load() + + refreshErr := errors.New("remote refresh unavailable") + store.record(nil, refreshErr) + failed := store.load() + if failed.Generation != success.Generation { + t.Fatalf("failed Generation = %d, want %d", failed.Generation, success.Generation) + } + if !failed.RefreshedAt.Equal(success.RefreshedAt) { + t.Fatalf("failed RefreshedAt = %v, want %v", failed.RefreshedAt, success.RefreshedAt) + } + if !reflect.DeepEqual(failed.Self, success.Self) || !reflect.DeepEqual(failed.Peers, success.Peers) { + t.Fatalf("failure replaced last-good data: failed=%#v success=%#v", failed, success) + } + if failed.LastAttemptAt.Before(success.LastAttemptAt) { + t.Fatalf("failed LastAttemptAt = %v, before successful attempt %v", failed.LastAttemptAt, success.LastAttemptAt) + } + if failed.LastError != refreshErr.Error() { + t.Fatalf("LastError = %q, want %q", failed.LastError, refreshErr) + } + + second := &control.MapResponse{Node: &control.Node{ + DID: "did:example:self", + Label: "second", + MeshIP: netip.MustParseAddr("10.200.0.1"), + }} + store.record(second, nil) + recovered := store.load() + if recovered.Generation != success.Generation+1 { + t.Fatalf("recovered Generation = %d, want %d", recovered.Generation, success.Generation+1) + } + if recovered.LastError != "" { + t.Fatalf("recovered LastError = %q, want empty", recovered.LastError) + } + if recovered.Self == nil || recovered.Self.Label != "second" { + t.Fatalf("recovered Self = %#v", recovered.Self) + } + if !recovered.RefreshedAt.Equal(recovered.LastAttemptAt) { + t.Fatalf("recovered timestamps differ: refreshed=%v attempted=%v", recovered.RefreshedAt, recovered.LastAttemptAt) + } +} + +func TestMeshSnapshotStoreRecordsFailureBeforeFirstSuccess(t *testing.T) { + store := &meshSnapshotStore{} + store.record(nil, errors.New("bootstrap failed")) + + got := (&Engine{snapshots: store}).MeshSnapshot() + if got == nil { + t.Fatal("snapshot is nil") + } + if got.Generation != 0 || !got.RefreshedAt.IsZero() || got.Self != nil || got.Peers != nil { + t.Fatalf("bootstrap failure snapshot = %#v", got) + } + if got.LastAttemptAt.IsZero() || got.LastError != "bootstrap failed" { + t.Fatalf("bootstrap failure metadata = %#v", got) + } +} + +func TestMapResponseFuncPublishesOnlyAfterSuccessfulConversion(t *testing.T) { + store := &meshSnapshotStore{} + converter := NewConverter("mesh.test") + successResp := &control.MapResponse{} + + successFn := mapResponseFunc( + func(context.Context) (*control.MapResponse, error) { return successResp, nil }, + converter, + store.record, + ) + if _, err := successFn(context.Background()); err != nil { + t.Fatalf("successful conversion: %v", err) + } + success := store.load() + if success == nil || success.Generation != 1 || success.LastError != "" { + t.Fatalf("successful snapshot = %#v", success) + } + + conversionFailureFn := mapResponseFunc( + func(context.Context) (*control.MapResponse, error) { return nil, nil }, + converter, + store.record, + ) + if _, err := conversionFailureFn(context.Background()); err == nil { + t.Fatal("nil MapResponse conversion unexpectedly succeeded") + } + afterConversionFailure := store.load() + if afterConversionFailure.Generation != success.Generation || + !afterConversionFailure.RefreshedAt.Equal(success.RefreshedAt) { + t.Fatalf("conversion failure replaced last good snapshot: %#v", afterConversionFailure) + } + if !strings.Contains(afterConversionFailure.LastError, "nil MapResponse") { + t.Fatalf("conversion LastError = %q", afterConversionFailure.LastError) + } + + loadFailure := errors.New("DWN unavailable") + loadFailureFn := mapResponseFunc( + func(context.Context) (*control.MapResponse, error) { return nil, loadFailure }, + converter, + store.record, + ) + if _, err := loadFailureFn(context.Background()); !errors.Is(err, loadFailure) { + t.Fatalf("load failure = %v, want wrapped %v", err, loadFailure) + } + afterLoadFailure := store.load() + if afterLoadFailure.Generation != success.Generation || + !afterLoadFailure.RefreshedAt.Equal(success.RefreshedAt) { + t.Fatalf("load failure replaced last good snapshot: %#v", afterLoadFailure) + } + if !strings.Contains(afterLoadFailure.LastError, "loading DWN state") { + t.Fatalf("load LastError = %q", afterLoadFailure.LastError) + } +} + +func TestMeshSnapshotStoreConcurrentReadersAndPublishers(t *testing.T) { + store := &meshSnapshotStore{} + resp := &control.MapResponse{ + Node: &control.Node{ + DID: "did:example:self", + Label: "self", + MeshIP: netip.MustParseAddr("10.200.0.1"), + }, + Peers: []*control.Node{{ + DID: "did:example:peer", + Label: "peer", + MeshIP: netip.MustParseAddr("10.200.0.2"), + Online: true, + LastSeen: time.Date(2026, 7, 11, 15, 0, 0, 0, time.UTC), + }}, + } + + const publishers = 4 + const successesPerPublisher = 100 + const readers = 8 + var wg sync.WaitGroup + errs := make(chan string, readers*successesPerPublisher) + + for range publishers { + wg.Add(1) + go func() { + defer wg.Done() + for i := range successesPerPublisher { + store.record(resp, nil) + if i%10 == 0 { + store.record(nil, errors.New("transient refresh failure")) + } + } + }() + } + for range readers { + wg.Add(1) + go func() { + defer wg.Done() + for range successesPerPublisher * 2 { + got := store.load() + if got == nil || got.Generation == 0 { + continue + } + if got.Self == nil || got.Self.NodeDID != "did:example:self" { + errs <- "successful snapshot missing self" + continue + } + if len(got.Peers) != 1 || got.Peers[0].NodeDID != "did:example:peer" { + errs <- "successful snapshot has invalid peers" + continue + } + // Mutating a reader's copy must be race-free and isolated. + got.Self.Label = "reader mutation" + got.Peers[0].Label = "reader mutation" + *got.Peers[0].LastSeen = time.Time{} + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } + + got := store.load() + wantGeneration := uint64(publishers * successesPerPublisher) + if got.Generation != wantGeneration { + t.Fatalf("Generation = %d, want %d", got.Generation, wantGeneration) + } + if got.Self.Label != "self" || got.Peers[0].Label != "peer" || got.Peers[0].LastSeen.IsZero() { + t.Fatalf("reader mutation reached stored snapshot: %#v", got) + } +}