WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
5 changed files with 137 additions and 1 deletions
Showing only changes of commit 8b5060faf5 - Show all commits
+2
View File
@@ -8,6 +8,7 @@ package ipn
import (
"maps"
"net/netip"
"time"
"tailscale.com/drive"
"tailscale.com/tailcfg"
@@ -38,6 +39,7 @@ var _LoginProfileCloneNeedsRegeneration = LoginProfile(struct {
NodeID tailcfg.StableNodeID
LocalUserID WindowsUserID
ControlURL string
Created time.Time
}{})
// Clone makes a deep copy of Prefs.
+8
View File
@@ -9,6 +9,7 @@ import (
jsonv1 "encoding/json"
"errors"
"net/netip"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
@@ -131,6 +132,12 @@ func (v LoginProfileView) LocalUserID() WindowsUserID { return v.ж.LocalUserID
// into.
func (v LoginProfileView) ControlURL() string { return v.ж.ControlURL }
// Created is when this profile was first added to this client. It is
// stamped once at profile creation and never changes. It is used to sort
// the profile list with newest first; profiles created before this field
// existed have a zero value and sort after all stamped profiles.
func (v LoginProfileView) Created() time.Time { return v.ж.Created }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _LoginProfileViewNeedsRegeneration = LoginProfile(struct {
ID ProfileID
@@ -141,6 +148,7 @@ var _LoginProfileViewNeedsRegeneration = LoginProfile(struct {
NodeID tailcfg.StableNodeID
LocalUserID WindowsUserID
ControlURL string
Created time.Time
}{})
// View returns a read-only view of Prefs.
+14 -1
View File
@@ -21,6 +21,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/persist"
@@ -64,6 +65,10 @@ type profileManager struct {
// Override for key.NewEmptyHardwareAttestationKey used for testing.
newEmptyHardwareAttestationKey func() (key.HardwareAttestationKey, error)
// clock supplies the current time when stamping LoginProfile.Created.
// Tests substitute a fake clock to make creation timestamps deterministic.
clock tstime.DefaultClock
}
// SetExtensionHost sets the [ExtensionHost] for the [profileManager].
@@ -250,7 +255,14 @@ func (pm *profileManager) allProfilesFor(uid ipn.WindowsUserID) []ipn.LoginProfi
}
}
slices.SortFunc(out, func(a, b ipn.LoginProfileView) int {
return cmp.Compare(a.Name(), b.Name())
// Legacy (zero Created) first, then stamped oldest-first.
if c := a.Created().Compare(b.Created()); c != 0 {
return c
}
if c := cmp.Compare(a.Name(), b.Name()); c != 0 {
return c
}
return cmp.Compare(a.NetworkProfile().DomainName, b.NetworkProfile().DomainName)
})
return out
}
@@ -413,6 +425,7 @@ func (pm *profileManager) setProfilePrefs(lp *ipn.LoginProfile, prefsIn ipn.Pref
if persist := prefsIn.Persist(); persist.Valid() && persist.NodeID() != "" && persist.UserProfile().LoginName() != "" {
// Generate an ID and [ipn.StateKey] now that we have the node info.
lp.ID, lp.Key = newUnusedID(pm.knownProfiles)
lp.Created = pm.clock.Now()
}
// Set the current user as the profile owner, unless the current user ID does
+106
View File
@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
@@ -19,6 +20,8 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/persist"
@@ -1229,3 +1232,106 @@ func TestDeleteProfileClearsState(t *testing.T) {
t.Fatalf("ReadState after delete: got err %v, want ErrStateNotExist", err)
}
}
// TestProfileSortOrder verifies that allProfilesFor sorts zero-Created
// (legacy) profiles before stamped profiles, with stamped profiles
// ordered oldest-first so the most recently added one lands at the
// bottom. Name then DomainName break ties within either group.
func TestProfileSortOrder(t *testing.T) {
pm, err := newProfileManagerWithGOOS(new(mem.Store), logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
if err != nil {
t.Fatal(err)
}
base := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC)
add := func(id ipn.ProfileID, name, domain string, created time.Time) {
pm.knownProfiles[id] = (&ipn.LoginProfile{
ID: id,
Name: name,
NetworkProfile: ipn.NetworkProfile{DomainName: domain},
Created: created,
}).View()
}
add("a", "alice", "example.com", time.Time{})
add("a2", "alice", "acme.com", time.Time{}) // legacy ties alice on Name; DomainName breaks it
add("b", "bob", "example.com", time.Time{})
add("c", "carol", "example.com", base)
add("d", "dave", "example.com", base.Add(time.Minute))
add("e", "eve", "example.com", base.Add(time.Minute)) // ties dave on Created; Name breaks it
add("e2", "eve", "acme.com", base.Add(time.Minute)) // ties eve on Created+Name; DomainName breaks it
got := pm.allProfilesFor("")
type want struct{ name, domain string }
wantOrder := []want{
// Legacy first, Name asc then DomainName asc.
{"alice", "acme.com"},
{"alice", "example.com"},
{"bob", "example.com"},
// Stamped, oldest first.
{"carol", "example.com"},
{"dave", "example.com"},
{"eve", "acme.com"},
{"eve", "example.com"},
}
if len(got) != len(wantOrder) {
t.Fatalf("got %d profiles, want %d", len(got), len(wantOrder))
}
for i, w := range wantOrder {
if got[i].Name() != w.name || got[i].NetworkProfile().DomainName != w.domain {
t.Errorf("position %d: got (%q,%q), want (%q,%q) (full: %v)",
i, got[i].Name(), got[i].NetworkProfile().DomainName, w.name, w.domain, profileNames(got))
}
}
}
func profileNames(ps []ipn.LoginProfileView) []string {
out := make([]string, len(ps))
for i, p := range ps {
out[i] = p.Name()
}
return out
}
// TestProfileCreatedStamped verifies that Created is set on profile creation
// from the injected clock, and is preserved across an update even when the
// clock advances.
func TestProfileCreatedStamped(t *testing.T) {
pm, err := newProfileManagerWithGOOS(new(mem.Store), logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
if err != nil {
t.Fatal(err)
}
start := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC)
clock := tstest.NewClock(tstest.ClockOpts{Start: start})
pm.clock = tstime.DefaultClock{Clock: clock}
pm.SwitchToNewProfile()
p := pm.CurrentPrefs().AsStruct()
p.Persist = &persist.Persist{
NodeID: "n1",
PrivateNodeKey: key.NewNode(),
UserProfile: tailcfg.UserProfile{
ID: 1,
LoginName: "alice@example.com",
},
}
if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err)
}
if got := pm.currentProfile.Created(); !got.Equal(start) {
t.Errorf("Created = %v after create, want %v", got, start)
}
// Advancing the clock and then performing an unrelated update (e.g.
// ProfileName change) must not move Created.
clock.Advance(time.Hour)
p = pm.CurrentPrefs().AsStruct()
p.ProfileName = "Alice"
if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err)
}
if got := pm.currentProfile.Created(); !got.Equal(start) {
t.Errorf("Created changed across update: got %v, want %v", got, start)
}
}
+7
View File
@@ -17,6 +17,7 @@ import (
"runtime"
"slices"
"strings"
"time"
"tailscale.com/atomicfile"
"tailscale.com/drive"
@@ -1091,6 +1092,12 @@ type LoginProfile struct {
// ControlURL is the URL of the control server that this profile is logged
// into.
ControlURL string
// Created is when this profile was first added to this client. It is
// stamped once at profile creation and never changes. It is used to sort
// the profile list with newest first; profiles created before this field
// existed have a zero value and sort after all stamped profiles.
Created time.Time `json:",omitzero"`
}
// Equals reports whether p and p2 are equal.