diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index 41299c5cf..9b25f790c 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -139,7 +139,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa tailscale.com/types/key from tailscale.com/client/local+ tailscale.com/types/lazy from tailscale.com/version+ tailscale.com/types/logger from tailscale.com/cmd/derper+ - tailscale.com/types/netmap from tailscale.com/ipn+ + tailscale.com/types/netmap from tailscale.com/net/routecheck tailscale.com/types/opt from tailscale.com/envknob+ tailscale.com/types/persist from tailscale.com/ipn+ tailscale.com/types/preftype from tailscale.com/ipn diff --git a/cmd/sniproxy/sniproxy.go b/cmd/sniproxy/sniproxy.go index f7ebc6aba..ce671b82f 100644 --- a/cmd/sniproxy/sniproxy.go +++ b/cmd/sniproxy/sniproxy.go @@ -138,10 +138,9 @@ func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, pro } // Finally, start mainloop to configure app connector based on information - // in the self node's CapMap. We set NotifyInitialNetMap so the first - // Notify carries the current self node (now via Notify.SelfChange); - // subsequent self changes wake us up too. - bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialNetMap) + // in the self node's CapMap. The initial status carries the current + // self node; subsequent self changes arrive via Notify.SelfChange. + bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialStatus) if err != nil { log.Fatalf("watching IPN bus: %v", err) } @@ -155,13 +154,16 @@ func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, pro log.Fatalf("reading IPN bus: %v", err) } - self := msg.SelfChange - if self == nil { + var capMap tailcfg.NodeCapMap + if self := msg.SelfChange; self != nil { + capMap = self.CapMap + } else if st := msg.InitialStatus; st != nil && st.Self != nil { + capMap = st.Self.CapMap + } else { continue } var c appctype.AppConnectorConfig - // View() lets us reuse the existing CapView decoder. - nmConf, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorConfig](self.View().CapMap(), configCapKey) + nmConf, err := tailcfg.UnmarshalNodeCapJSON[appctype.AppConnectorConfig](capMap, configCapKey) if err != nil { log.Printf("failed to read app connector configuration from coordination server: %v", err) } else if len(nmConf) > 0 { diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index d6c816f27..a3cb67d5d 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -275,7 +275,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/types/key from tailscale.com/client/local+ tailscale.com/types/lazy from tailscale.com/util/testenv+ tailscale.com/types/logger from tailscale.com/client/web+ - tailscale.com/types/netmap from tailscale.com/ipn+ + tailscale.com/types/netmap from tailscale.com/net/routecheck tailscale.com/types/nettype from tailscale.com/net/netcheck+ tailscale.com/types/opt from tailscale.com/client/tailscale+ tailscale.com/types/persist from tailscale.com/ipn+ diff --git a/ipn/backend.go b/ipn/backend.go index 03d92edb1..5744155ca 100644 --- a/ipn/backend.go +++ b/ipn/backend.go @@ -17,7 +17,6 @@ import ( "tailscale.com/tailcfg" "tailscale.com/types/empty" "tailscale.com/types/key" - "tailscale.com/types/netmap" "tailscale.com/types/structs" "tailscale.com/types/views" "tailscale.com/util/syspolicy/policyclient" @@ -86,9 +85,19 @@ const ( // each one via Engine.RequestStatus. NotifyWatchEngineUpdates NotifyWatchOpt = 1 << 0 - NotifyInitialState NotifyWatchOpt = 1 << 1 // if set, the first Notify message (sent immediately) will contain the current State + BrowseToURL + SessionID - NotifyInitialPrefs NotifyWatchOpt = 1 << 2 // if set, the first Notify message (sent immediately) will contain the current Prefs - NotifyInitialNetMap NotifyWatchOpt = 1 << 3 // if set, the first Notify message (sent immediately) will contain the current NetMap + NotifyInitialState NotifyWatchOpt = 1 << 1 // if set, the first Notify message (sent immediately) will contain the current State + BrowseToURL + SessionID + NotifyInitialPrefs NotifyWatchOpt = 1 << 2 // if set, the first Notify message (sent immediately) will contain the current Prefs + + // ObsoleteNotifyInitialNetMap is a formerly valid bit (previously named + // NotifyInitialNetMap) that asked for the first Notify message to + // carry the full netmap in the since-removed Notify.NetMap field. + // Subscription requests that set it are now rejected by + // [ValidateNotifyWatchOpt]. Watchers seed their view from + // [NotifyInitialStatus] instead, and those that need more than the + // status fetch what they need via other LocalAPI methods. The bit + // value remains reserved so it is never reused with a different + // meaning. + ObsoleteNotifyInitialNetMap NotifyWatchOpt = 1 << 3 NotifyNoPrivateKeys NotifyWatchOpt = 1 << 4 // (no-op) it used to redact private keys; now they always are and this does nothing NotifyInitialDriveShares NotifyWatchOpt = 1 << 5 // if set, the first Notify message (sent immediately) will contain the current Taildrive Shares @@ -128,27 +137,29 @@ const ( // observes every per-peer mutation; it just receives them as full // Nodes rather than narrow patches. The cost is bus bandwidth. // - // It is permitted to combine this with [NotifyInitialNetMap] for - // backwards compatibility. New code should pair this with - // [NotifyInitialStatus] instead. + // Watchers typically pair this with [NotifyInitialStatus] to seed + // their initial state. NotifyPeerChanges NotifyWatchOpt = 1 << 12 - // NotifyNoNetMap historically suppressed the legacy [Notify.NetMap] + // NotifyNoNetMap historically suppressed the legacy Notify.NetMap // field on runtime (non-initial) Notify messages on platforms where - // tailscaled still emitted it by default. tailscaled no longer emits - // NetMap on runtime messages on any platform, so this bit is now a - // no-op. It remains accepted for compatibility with clients that set - // it. The initial-state NetMap (sent when [NotifyInitialNetMap] is - // set) was never affected by this bit. + // tailscaled still emitted it by default. That field no longer + // exists, so this bit is now a no-op. It remains accepted for + // compatibility with clients that set it. NotifyNoNetMap NotifyWatchOpt = 1 << 13 // NotifyInitialStatus, if set, causes the first Notify message (sent // immediately) to contain the current [ipnstate.Status] in // [Notify.InitialStatus]. Together with [Notify.SelfChange] and // [Notify.PeersChanged] on subsequent messages, it lets a watcher - // stitch together a continuous view of the local node's state without - // fetching the netmap directly. Prefer this over [LocalClient.NetMap] - // for new code that wants a stable, client-facing snapshot type. + // stitch together a continuous view of the local node's state as a + // stable, client-facing snapshot type, without needing the netmap. + // + // The status is sized to the subscription: its per-peer entries + // (Status.Peer) are only populated if the watcher also set + // [NotifyPeerChanges] or [NotifyPeerPatches], since building them is + // O(peers) and only peer-delta subscribers need a peer baseline. + // Self-only watchers get Status.Self and the scalar fields. NotifyInitialStatus NotifyWatchOpt = 1 << 14 // NotifyPeerPatches, if set, opts the watcher into narrow per-field @@ -226,7 +237,7 @@ func (o NotifyWatchOpt) String() string { try(NotifyWatchEngineUpdates, "NotifyWatchEngineUpdates") try(NotifyInitialState, "NotifyInitialState") try(NotifyInitialPrefs, "NotifyInitialPrefs") - try(NotifyInitialNetMap, "NotifyInitialNetMap") + try(ObsoleteNotifyInitialNetMap, "ObsoleteNotifyInitialNetMap") try(NotifyNoPrivateKeys, "NotifyNoPrivateKeys") try(NotifyInitialDriveShares, "NotifyInitialDriveShares") try(NotifyInitialOutgoingFiles, "NotifyInitialOutgoingFiles") @@ -283,6 +294,9 @@ func ValidateNotifyWatchOpt(mask NotifyWatchOpt) error { if mask&ObsoleteNotifyRateLimit != 0 { return errors.New("the NotifyRateLimit IPN bus subscription bit is no longer supported") } + if mask&ObsoleteNotifyInitialNetMap != 0 { + return errors.New("the NotifyInitialNetMap IPN bus subscription bit is no longer supported; seed from NotifyInitialStatus instead") + } return nil } @@ -318,9 +332,15 @@ type Notify struct { // sniproxy, etc.) can read the current self state without watching the // full netmap. // - // Consumers that need additional state (peers, DNS config, packet - // filter) should react to SelfChange by fetching the full netmap on - // demand via [LocalClient.NetMap]. + // Runtime self changes are not gated by any subscription bit: every + // watcher receives them. There is no initial SelfChange; watchers + // that need the current self node at the start of a session should + // set [NotifyInitialStatus] and seed from InitialStatus.Self. + // + // Consumers that need additional state (peers, DNS config, etc.) + // should react to SelfChange by fetching what they need via other + // LocalAPI methods, such as LocalClient.Status or + // LocalClient.DNSConfig. SelfChange *tailcfg.Node `json:",omitzero"` // InitialStatus, if non-nil, is the current [ipnstate.Status]. It is @@ -330,18 +350,6 @@ type Notify struct { // a continuous view of node state without fetching the netmap. InitialStatus *ipnstate.Status `json:",omitzero"` - // NetMap, if non-nil, is the full network map. It is only delivered - // on the initial Notify of a session, and only when the watcher - // requested [NotifyInitialNetMap]; it is always nil on subsequent - // messages. New consumers should prefer [LocalClient.NetMap] for - // one-shot fetches and [Notify.SelfChange] / [Notify.PeerChanges] - // for incremental reactive updates. - // - // Deprecated: this field is slated for removal in favor of - // [Notify.InitialStatus] + [Notify.SelfChange] / - // [Notify.PeerChanges], etc, as this field doesn't scale. - NetMap *netmap.NetworkMap - // PeerChangedPatch, if non-empty, lists narrow per-field peer patches // since the last Notify (currently Online, LastSeen, DERPHome, // Endpoints). It mirrors [tailcfg.MapResponse.PeersChangedPatch]. @@ -392,8 +400,8 @@ type Notify struct { // The producer guarantees that any UserID referenced by a peer in // a [Notify.PeersChanged] / [Notify.PeerChangedPatch] entry will // have its profile delivered either earlier on this same session - // (e.g. via the initial NetMap or via an earlier Notify carrying - // UserProfiles) or in this same Notify. A consumer that sees a + // (via an earlier Notify carrying UserProfiles) or in this same + // Notify. A consumer that sees a // UserID it doesn't recognize on a session that opted in to // peer-change notifications can treat it as a bug; the // [LocalClient.UserProfile] LocalAPI fallback exists for sessions diff --git a/ipn/backend_test.go b/ipn/backend_test.go index ce9fdc95c..4241570b6 100644 --- a/ipn/backend_test.go +++ b/ipn/backend_test.go @@ -110,6 +110,11 @@ func TestValidateNotifyWatchOpt(t *testing.T) { mask: ObsoleteNotifyRateLimit | NotifyPeerChanges, wantErr: true, }, + { + name: "obsolete-initial-netmap", + mask: ObsoleteNotifyInitialNetMap, + wantErr: true, + }, } for _, tt := range tests { diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 91fb43696..c8db27091 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -2071,7 +2071,7 @@ func (b *LocalBackend) setControlClientStatusLocked(c controlclient.Client, st c // Notify watchers that the self node may have changed. Reactive // consumers (containerboot, kube agents, sniproxy, etc.) listen on - // this signal and re-fetch peers/DNS via [LocalClient.NetMap] if + // this signal and re-fetch peers/DNS via other LocalAPI methods if // they need more than self info. var selfChange *tailcfg.Node if st.NetMap.SelfNode.Valid() { @@ -3741,7 +3741,13 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A // channel before InitialStatus is delivered. var statusSB *ipnstate.StatusBuilder if mask&ipn.NotifyInitialStatus != 0 { - statusSB = &ipnstate.StatusBuilder{WantPeers: true} + // The initial status is sized to the subscription: building the + // per-peer status entries is O(peers), so only do it for watchers + // that subscribed to peer deltas and thus need a peer baseline to + // apply them to. Self-only watchers get Status.Self and the + // scalar fields. + wantPeers := mask&(ipn.NotifyPeerChanges|ipn.NotifyPeerPatches) != 0 + statusSB = &ipnstate.StatusBuilder{WantPeers: wantPeers} b.e.UpdateStatus(statusSB) } if mask&ipn.NotifyPeerWireGuardState != 0 { @@ -3756,11 +3762,10 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A var policyUID string const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | - ipn.NotifyInitialNetMap | ipn.NotifyInitialStatus | + ipn.NotifyInitialStatus | ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode | ipn.NotifyInitialClientVersion | ipn.NotifySysPolicyChanges | ipn.NotifyPeerWireGuardState if mask&initialBits != 0 { - cn := b.currentNode() ini = &ipn.Notify{Version: version.Long()} if mask&ipn.NotifyInitialState != 0 { ini.SessionID = sessionID @@ -3772,16 +3777,6 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A if mask&ipn.NotifyInitialPrefs != 0 { ini.Prefs = new(b.sanitizedPrefsLocked()) } - if mask&ipn.NotifyInitialNetMap != 0 { - if nm := cn.NetMap(); nm != nil && nm.SelfNode.Valid() { - ini.SelfChange = nm.SelfNode.AsStruct() - } - // The legacy initial NetMap is delivered cross-platform: it - // is what watchers asked for by setting NotifyInitialNetMap - // and is always a one-shot, so the cost of building it is - // paid once per bus subscription. - ini.NetMap = cn.netMapWithPeers() - } if statusSB != nil { b.updateStatusLocked(statusSB) ini.InitialStatus = statusSB.Status() diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 87b423fa0..2a3da48eb 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -2470,9 +2470,6 @@ func TestSetControlClientStatusSendsFullNetmapAsPeerChanges(t *testing.T) { if n.SelfChange == nil { return false } - if n.NetMap != nil { - t.Errorf("NetMap was delivered to NotifyNoNetMap watcher") - } if got, want := len(n.PeersChanged), 2; got != want { t.Errorf("PeersChanged len = %d; want %d", got, want) return false @@ -2510,6 +2507,55 @@ func TestSetControlClientStatusSendsFullNetmapAsPeerChanges(t *testing.T) { nw.check() } +// TestWatchNotificationsInitialStatusPeers verifies that the initial +// status is sized to the subscription: Status.Peer entries are only +// populated for watchers that subscribed to peer deltas, while +// Status.Self is populated either way. +func TestWatchNotificationsInitialStatusPeers(t *testing.T) { + tests := []struct { + name string + mask ipn.NotifyWatchOpt + wantPeers bool + }{ + {"self-only", ipn.NotifyInitialStatus, false}, + {"peer-changes", ipn.NotifyInitialStatus | ipn.NotifyPeerChanges, true}, + {"peer-patches", ipn.NotifyInitialStatus | ipn.NotifyPeerPatches, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := newTestLocalBackend(t) + b.currentNode().SetNetMap(&netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + ID: 1, + User: 1, + Key: makeNodeKeyFromID(1), + }).View(), + Peers: []tailcfg.NodeView{ + (&tailcfg.Node{ID: 10, User: 1, Key: makeNodeKeyFromID(10)}).View(), + }, + }) + + nw := newNotificationWatcher(t, b, ipnauth.Self) + nw.watch(tt.mask, []wantedNotification{{ + name: "initial status", + cond: func(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool { + if n.InitialStatus == nil { + return false + } + if n.InitialStatus.Self == nil { + t.Errorf("InitialStatus.Self = nil; want non-nil") + } + if got := len(n.InitialStatus.Peer); (got > 0) != tt.wantPeers { + t.Errorf("len(InitialStatus.Peer) = %d; wantPeers = %v", got, tt.wantPeers) + } + return true + }, + }}) + nw.check() + }) + } +} + type expiryCallbackClock struct { tstime.StdClock now time.Time @@ -5248,6 +5294,11 @@ func TestDriveManageShares(t *testing.T) { 0, func() { wg.Done() }, func(n *ipn.Notify) bool { + if n.DriveShares.IsNil() { + // Skip unrelated notifications, such as the + // initial SelfChange sent to every watcher. + return true + } select { case result <- n.DriveShares: default: diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index 9178a814c..85a802c82 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -154,8 +154,8 @@ type nodeBackend struct { // by mergeUserProfiles as deltas arrive. It parallels the peers map: // netMap.UserProfiles is the frozen snapshot from the last full install, // while this field reflects incremental updates. Readers that need a - // snapshot (e.g. the initial-Notify NetMap or [LocalClient.NetMap] - // paths) must clone this map. + // snapshot (e.g. [LocalBackend.NetMapWithPeers] callers such as the + // c2n and debug netmap handlers) must clone this map. userProfiles map[tailcfg.UserID]tailcfg.UserProfileView // packetFilterRules and packetFilter are the live packet filter state, diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 4fa44ee9d..d864396ec 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -910,10 +910,6 @@ func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - // NotifyInitialNetMap is permitted alongside NotifyPeerChanges / - // NotifyPeerPatches for backwards compatibility with clients that - // set both (e.g. the Apple client). The initial netmap is a one-shot; - // peer-change subscribers simply receive deltas after that point. w.Header().Set("Content-Type", "application/json") ctx := r.Context() diff --git a/kube/certs/certs.go b/kube/certs/certs.go index b8e6ccf14..ec8956b33 100644 --- a/kube/certs/certs.go +++ b/kube/certs/certs.go @@ -207,10 +207,11 @@ func (cm *CertManager) runCertLoop(ctx context.Context, domain string) { } // domains before issuing the cert for the first time. It uses the IPN bus -// only as a wake-up trigger (Notify.SelfChange) and queries the current -// cert domains explicitly via [LocalClient.CertDomains]. +// only as a wake-up trigger (the initial status and subsequent +// Notify.SelfChange messages) and queries the current cert domains +// explicitly via [LocalClient.CertDomains]. func (cm *CertManager) waitForCertDomain(ctx context.Context, domain string) error { - w, err := cm.lc.WatchIPNBus(ctx, ipn.NotifyInitialNetMap) + w, err := cm.lc.WatchIPNBus(ctx, ipn.NotifyInitialStatus) if err != nil { return fmt.Errorf("error watching IPN bus: %w", err) } @@ -221,7 +222,7 @@ func (cm *CertManager) waitForCertDomain(ctx context.Context, domain string) err if err != nil { return err } - if n.SelfChange == nil { + if n.SelfChange == nil && n.InitialStatus == nil { continue } domains, err := cm.lc.CertDomains(ctx) diff --git a/kube/health/healthz.go b/kube/health/healthz.go index 8909e79a4..6112231df 100644 --- a/kube/health/healthz.go +++ b/kube/health/healthz.go @@ -60,7 +60,7 @@ func (h *Healthz) Update(healthy bool) { } func (h *Healthz) MonitorHealth(ctx context.Context, lc *local.Client) error { - w, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialNetMap) + w, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialStatus) if err != nil { return fmt.Errorf("failed to watch IPN bus: %w", err) } @@ -73,6 +73,8 @@ func (h *Healthz) MonitorHealth(ctx context.Context, lc *local.Client) error { if self := n.SelfChange; self != nil { h.Update(len(self.Addresses) != 0) + } else if st := n.InitialStatus; st != nil && st.Self != nil { + h.Update(len(st.Self.TailscaleIPs) != 0) } } } diff --git a/kube/state/state.go b/kube/state/state.go index 220eb439f..4d6b21884 100644 --- a/kube/state/state.go +++ b/kube/state/state.go @@ -14,12 +14,14 @@ import ( "context" "encoding/json" "fmt" + "net/netip" + "slices" "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" "tailscale.com/kube/kubetypes" klc "tailscale.com/kube/localclient" "tailscale.com/tailcfg" - "tailscale.com/util/deephash" ) const ( @@ -44,43 +46,39 @@ func SetInitialKeys(store ipn.StateStore, podUID string) error { // KeepKeysUpdated sets state store keys consistent with containerboot to // signal proxy readiness to the operator. It runs until its context is -// cancelled or it hits an error. It watches the IPN bus for SelfChange -// notifications (which fire whenever the self node changes) and reads -// the new self node directly from the notify. +// cancelled or it hits an error. It seeds the self node from the initial +// status and then watches the IPN bus for SelfChange notifications, which +// fire whenever the self node changes. func KeepKeysUpdated(ctx context.Context, store ipn.StateStore, lc klc.LocalClient) error { - w, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialNetMap) + w, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialStatus) if err != nil { return fmt.Errorf("error watching IPN bus: %w", err) } defer w.Close() - var currentDeviceID, currentDeviceIPs, currentDeviceFQDN deephash.Sum - for { - n, err := w.Next() // Blocks on a streaming LocalAPI HTTP call. - if err != nil { - if err == ctx.Err() { - return nil - } - return err - } - self := n.SelfChange - if self == nil { - continue - } + var prevDeviceID tailcfg.StableNodeID + var prevFQDN string + var prevAddrs []netip.Prefix - if deviceID := self.StableID; deephash.Update(¤tDeviceID, &deviceID) { + // storeSelf writes the device ID, FQDN, and IP state keys derived + // from the given self node, skipping any whose value is unchanged + // since the last write. + storeSelf := func(self tailcfg.NodeView) error { + if deviceID := self.StableID(); deviceID != prevDeviceID { if err := store.WriteState(keyDeviceID, []byte(deviceID)); err != nil { return fmt.Errorf("failed to store device ID in state: %w", err) } + prevDeviceID = deviceID } - if fqdn := self.Name; deephash.Update(¤tDeviceFQDN, &fqdn) { + if fqdn := self.Name(); fqdn != prevFQDN { if err := store.WriteState(keyDeviceFQDN, []byte(fqdn)); err != nil { return fmt.Errorf("failed to store device FQDN in state: %w", err) } + prevFQDN = fqdn } - if addrs := self.Addresses; deephash.Update(¤tDeviceIPs, &addrs) { + if addrs := self.Addresses().AsSlice(); !slices.Equal(addrs, prevAddrs) { var deviceIPs []string for _, addr := range addrs { deviceIPs = append(deviceIPs, addr.Addr().String()) @@ -92,6 +90,45 @@ func KeepKeysUpdated(ctx context.Context, store ipn.StateStore, lc klc.LocalClie if err := store.WriteState(keyDeviceIPs, deviceIPsValue); err != nil { return fmt.Errorf("failed to store device IPs in state: %w", err) } + prevAddrs = addrs + } + return nil + } + + for { + n, err := w.Next() // Blocks on a streaming LocalAPI HTTP call. + if err != nil { + if err == ctx.Err() { + return nil + } + return err + } + + var self tailcfg.NodeView + switch { + case n.SelfChange != nil: + self = n.SelfChange.View() + case n.InitialStatus != nil && n.InitialStatus.Self != nil: + self = selfNodeFromPeerStatus(n.InitialStatus.Self) + default: + continue + } + if err := storeSelf(self); err != nil { + return err } } } + +// selfNodeFromPeerStatus converts the subset of ps that KeepKeysUpdated +// reads into a [tailcfg.NodeView], so the initial status seed and +// subsequent [ipn.Notify.SelfChange] updates can share one code path. +func selfNodeFromPeerStatus(ps *ipnstate.PeerStatus) tailcfg.NodeView { + n := &tailcfg.Node{ + StableID: ps.ID, + Name: ps.DNSName, + } + for _, ip := range ps.TailscaleIPs { + n.Addresses = append(n.Addresses, netip.PrefixFrom(ip, ip.BitLen())) + } + return n.View() +} diff --git a/tsnet/tsnet_test.go b/tsnet/tsnet_test.go index 94c42f33e..9aa83d0a1 100644 --- a/tsnet/tsnet_test.go +++ b/tsnet/tsnet_test.go @@ -1182,7 +1182,7 @@ func setUpServiceState(t *testing.T, name, ip string, host, client *Server, } waitForLatestNetmap := func(t *testing.T, s *Server) { t.Helper() - w := must.Get(s.localClient.WatchIPNBus(t.Context(), ipn.NotifyInitialNetMap)) + w := must.Get(s.localClient.WatchIPNBus(t.Context(), ipn.NotifyInitialStatus)) defer w.Close() for { must.Get(w.Next())