Просмотр исходного кода

cmd/tailscale,feature/relayserver,ipn: add relay-server-static-endpoints set flag

Updates tailscale/corp#31489
Updates #17791

Signed-off-by: Jordan Whited <[email protected]>
Jordan Whited 3 месяцев назад
Родитель
Сommit
7426eca163

+ 42 - 23
cmd/tailscale/cli/set.go

@@ -11,6 +11,7 @@ import (
 	"net/netip"
 	"os/exec"
 	"runtime"
+	"slices"
 	"strconv"
 	"strings"
 
@@ -25,6 +26,7 @@ import (
 	"tailscale.com/types/opt"
 	"tailscale.com/types/ptr"
 	"tailscale.com/types/views"
+	"tailscale.com/util/set"
 	"tailscale.com/version"
 )
 
@@ -43,29 +45,30 @@ Only settings explicitly mentioned will be set. There are no default values.`,
 }
 
 type setArgsT struct {
-	acceptRoutes           bool
-	acceptDNS              bool
-	exitNodeIP             string
-	exitNodeAllowLANAccess bool
-	shieldsUp              bool
-	runSSH                 bool
-	runWebClient           bool
-	hostname               string
-	advertiseRoutes        string
-	advertiseDefaultRoute  bool
-	advertiseConnector     bool
-	opUser                 string
-	acceptedRisks          string
-	profileName            string
-	forceDaemon            bool
-	updateCheck            bool
-	updateApply            bool
-	reportPosture          bool
-	snat                   bool
-	statefulFiltering      bool
-	sync                   bool
-	netfilterMode          string
-	relayServerPort        string
+	acceptRoutes               bool
+	acceptDNS                  bool
+	exitNodeIP                 string
+	exitNodeAllowLANAccess     bool
+	shieldsUp                  bool
+	runSSH                     bool
+	runWebClient               bool
+	hostname                   string
+	advertiseRoutes            string
+	advertiseDefaultRoute      bool
+	advertiseConnector         bool
+	opUser                     string
+	acceptedRisks              string
+	profileName                string
+	forceDaemon                bool
+	updateCheck                bool
+	updateApply                bool
+	reportPosture              bool
+	snat                       bool
+	statefulFiltering          bool
+	sync                       bool
+	netfilterMode              string
+	relayServerPort            string
+	relayServerStaticEndpoints string
 }
 
 func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
@@ -88,6 +91,7 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
 	setf.BoolVar(&setArgs.runWebClient, "webclient", false, "expose the web interface for managing this node over Tailscale at port 5252")
 	setf.BoolVar(&setArgs.sync, "sync", false, hidden+"actively sync configuration from the control plane (set to false only for network failure testing)")
 	setf.StringVar(&setArgs.relayServerPort, "relay-server-port", "", "UDP port number (0 will pick a random unused port) for the relay server to bind to, on all interfaces, or empty string to disable relay server functionality")
+	setf.StringVar(&setArgs.relayServerStaticEndpoints, "relay-server-static-endpoints", "", "static IP:port endpoints to advertise as candidates for relay connections (comma-separated, e.g. \"[2001:db8::1]:40000,192.0.2.1:40000\") or empty string to not advertise any static endpoints")
 
 	ffcomplete.Flag(setf, "exit-node", func(args []string) ([]string, ffcomplete.ShellCompDirective, error) {
 		st, err := localClient.Status(context.Background())
@@ -248,6 +252,21 @@ func runSet(ctx context.Context, args []string) (retErr error) {
 		maskedPrefs.Prefs.RelayServerPort = ptr.To(int(uport))
 	}
 
+	if setArgs.relayServerStaticEndpoints != "" {
+		endpointsSet := make(set.Set[netip.AddrPort])
+		endpointsSplit := strings.Split(setArgs.relayServerStaticEndpoints, ",")
+		for _, s := range endpointsSplit {
+			ap, err := netip.ParseAddrPort(s)
+			if err != nil {
+				return fmt.Errorf("failed to set relay server static endpoints: %q is not a valid IP:port", s)
+			}
+			endpointsSet.Add(ap)
+		}
+		endpoints := endpointsSet.Slice()
+		slices.SortFunc(endpoints, netip.AddrPort.Compare)
+		maskedPrefs.Prefs.RelayServerStaticEndpoints = endpoints
+	}
+
 	checkPrefs := curPrefs.Clone()
 	checkPrefs.ApplyEdits(maskedPrefs)
 	if err := localClient.CheckPrefs(ctx, checkPrefs); err != nil {

+ 1 - 0
cmd/tailscale/cli/up.go

@@ -887,6 +887,7 @@ func init() {
 	addPrefFlagMapping("report-posture", "PostureChecking")
 	addPrefFlagMapping("relay-server-port", "RelayServerPort")
 	addPrefFlagMapping("sync", "Sync")
+	addPrefFlagMapping("relay-server-static-endpoints", "RelayServerStaticEndpoints")
 }
 
 func addPrefFlagMapping(flagName string, prefNames ...string) {

+ 19 - 6
feature/relayserver/relayserver.go

@@ -9,6 +9,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"net/netip"
 
 	"tailscale.com/disco"
 	"tailscale.com/feature"
@@ -23,6 +24,7 @@ import (
 	"tailscale.com/types/key"
 	"tailscale.com/types/logger"
 	"tailscale.com/types/ptr"
+	"tailscale.com/types/views"
 	"tailscale.com/util/eventbus"
 	"tailscale.com/wgengine/magicsock"
 )
@@ -85,6 +87,7 @@ type relayServer interface {
 	AllocateEndpoint(discoA, discoB key.DiscoPublic) (endpoint.ServerEndpoint, error)
 	GetSessions() []status.ServerSession
 	SetDERPMapView(tailcfg.DERPMapView)
+	SetStaticAddrPorts(addrPorts views.Slice[netip.AddrPort])
 }
 
 // extension is an [ipnext.Extension] managing the relay server on platforms
@@ -95,12 +98,13 @@ type extension struct {
 	ec          *eventbus.Client
 	respPub     *eventbus.Publisher[magicsock.UDPRelayAllocResp]
 
-	mu                            syncs.Mutex         // guards the following fields
-	shutdown                      bool                // true if Shutdown() has been called
-	rs                            relayServer         // nil when disabled
-	port                          *int                // ipn.Prefs.RelayServerPort, nil if disabled
-	derpMapView                   tailcfg.DERPMapView // latest seen over the eventbus
-	hasNodeAttrDisableRelayServer bool                // [tailcfg.NodeAttrDisableRelayServer]
+	mu                            syncs.Mutex                 // guards the following fields
+	shutdown                      bool                        // true if Shutdown() has been called
+	rs                            relayServer                 // nil when disabled
+	port                          *int                        // ipn.Prefs.RelayServerPort, nil if disabled
+	staticEndpoints               views.Slice[netip.AddrPort] // ipn.Prefs.RelayServerStaticEndpoints
+	derpMapView                   tailcfg.DERPMapView         // latest seen over the eventbus
+	hasNodeAttrDisableRelayServer bool                        // [tailcfg.NodeAttrDisableRelayServer]
 }
 
 // Name implements [ipnext.Extension].
@@ -186,6 +190,7 @@ func (e *extension) relayServerShouldBeRunningLocked() bool {
 
 // handleRelayServerLifetimeLocked handles the lifetime of [e.rs].
 func (e *extension) handleRelayServerLifetimeLocked() {
+	defer e.handleRelayServerStaticAddrPortsLocked()
 	if !e.relayServerShouldBeRunningLocked() {
 		e.stopRelayServerLocked()
 		return
@@ -195,6 +200,13 @@ func (e *extension) handleRelayServerLifetimeLocked() {
 	e.tryStartRelayServerLocked()
 }
 
+func (e *extension) handleRelayServerStaticAddrPortsLocked() {
+	if e.rs != nil {
+		// TODO(jwhited): env var support
+		e.rs.SetStaticAddrPorts(e.staticEndpoints)
+	}
+}
+
 func (e *extension) selfNodeViewChanged(nodeView tailcfg.NodeView) {
 	e.mu.Lock()
 	defer e.mu.Unlock()
@@ -205,6 +217,7 @@ func (e *extension) selfNodeViewChanged(nodeView tailcfg.NodeView) {
 func (e *extension) profileStateChanged(_ ipn.LoginProfileView, prefs ipn.PrefsView, sameNode bool) {
 	e.mu.Lock()
 	defer e.mu.Unlock()
+	e.staticEndpoints = prefs.RelayServerStaticEndpoints()
 	newPort, ok := prefs.RelayServerPort().GetOk()
 	enableOrDisableServer := ok != (e.port != nil)
 	portChanged := ok && e.port != nil && newPort != *e.port

+ 74 - 8
feature/relayserver/relayserver_test.go

@@ -5,7 +5,9 @@ package relayserver
 
 import (
 	"errors"
+	"net/netip"
 	"reflect"
+	"slices"
 	"testing"
 
 	"tailscale.com/ipn"
@@ -17,15 +19,21 @@ import (
 	"tailscale.com/types/key"
 	"tailscale.com/types/logger"
 	"tailscale.com/types/ptr"
+	"tailscale.com/types/views"
 )
 
 func Test_extension_profileStateChanged(t *testing.T) {
 	prefsWithPortOne := ipn.Prefs{RelayServerPort: ptr.To(1)}
 	prefsWithNilPort := ipn.Prefs{RelayServerPort: nil}
+	prefsWithPortOneRelayEndpoints := ipn.Prefs{
+		RelayServerPort:            ptr.To(1),
+		RelayServerStaticEndpoints: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:7777")},
+	}
 
 	type fields struct {
-		port *int
-		rs   relayServer
+		port            *int
+		staticEndpoints views.Slice[netip.AddrPort]
+		rs              relayServer
 	}
 	type args struct {
 		prefs    ipn.PrefsView
@@ -38,6 +46,7 @@ func Test_extension_profileStateChanged(t *testing.T) {
 		wantPort                    *int
 		wantRelayServerFieldNonNil  bool
 		wantRelayServerFieldMutated bool
+		wantEndpoints               []netip.AddrPort
 	}{
 		{
 			name: "no changes non-nil port previously running",
@@ -53,6 +62,52 @@ func Test_extension_profileStateChanged(t *testing.T) {
 			wantRelayServerFieldNonNil:  true,
 			wantRelayServerFieldMutated: false,
 		},
+		{
+			name: "set addr ports unchanged port previously running",
+			fields: fields{
+				port: ptr.To(1),
+				rs:   mockRelayServerNotZeroVal(),
+			},
+			args: args{
+				prefs:    prefsWithPortOneRelayEndpoints.View(),
+				sameNode: true,
+			},
+			wantPort:                    ptr.To(1),
+			wantRelayServerFieldNonNil:  true,
+			wantRelayServerFieldMutated: false,
+			wantEndpoints:               prefsWithPortOneRelayEndpoints.RelayServerStaticEndpoints,
+		},
+		{
+			name: "set addr ports not previously running",
+			fields: fields{
+				port: nil,
+				rs:   nil,
+			},
+			args: args{
+				prefs:    prefsWithPortOneRelayEndpoints.View(),
+				sameNode: true,
+			},
+			wantPort:                    ptr.To(1),
+			wantRelayServerFieldNonNil:  true,
+			wantRelayServerFieldMutated: true,
+			wantEndpoints:               prefsWithPortOneRelayEndpoints.RelayServerStaticEndpoints,
+		},
+		{
+			name: "clear addr ports unchanged port previously running",
+			fields: fields{
+				port:            ptr.To(1),
+				staticEndpoints: views.SliceOf(prefsWithPortOneRelayEndpoints.RelayServerStaticEndpoints),
+				rs:              mockRelayServerNotZeroVal(),
+			},
+			args: args{
+				prefs:    prefsWithPortOne.View(),
+				sameNode: true,
+			},
+			wantPort:                    ptr.To(1),
+			wantRelayServerFieldNonNil:  true,
+			wantRelayServerFieldMutated: false,
+			wantEndpoints:               nil,
+		},
 		{
 			name: "prefs port nil",
 			fields: fields{
@@ -160,6 +215,7 @@ func Test_extension_profileStateChanged(t *testing.T) {
 				return &mockRelayServer{}, nil
 			}
 			e.port = tt.fields.port
+			e.staticEndpoints = tt.fields.staticEndpoints
 			e.rs = tt.fields.rs
 			defer e.Shutdown()
 			e.profileStateChanged(ipn.LoginProfileView{}, tt.args.prefs, tt.args.sameNode)
@@ -174,24 +230,34 @@ func Test_extension_profileStateChanged(t *testing.T) {
 			if tt.wantRelayServerFieldMutated != !reflect.DeepEqual(tt.fields.rs, e.rs) {
 				t.Errorf("wantRelayServerFieldMutated: %v != !reflect.DeepEqual(tt.fields.rs, e.rs): %v", tt.wantRelayServerFieldMutated, !reflect.DeepEqual(tt.fields.rs, e.rs))
 			}
+			if !slices.Equal(tt.wantEndpoints, e.staticEndpoints.AsSlice()) {
+				t.Errorf("wantEndpoints: %v != %v", tt.wantEndpoints, e.staticEndpoints.AsSlice())
+			}
+			if e.rs != nil && !slices.Equal(tt.wantEndpoints, e.rs.(*mockRelayServer).addrPorts.AsSlice()) {
+				t.Errorf("wantEndpoints: %v != %v", tt.wantEndpoints, e.rs.(*mockRelayServer).addrPorts.AsSlice())
+			}
 		})
 	}
 }
 
 func mockRelayServerNotZeroVal() *mockRelayServer {
-	return &mockRelayServer{true}
+	return &mockRelayServer{set: true}
 }
 
 type mockRelayServer struct {
-	set bool
+	set       bool
+	addrPorts views.Slice[netip.AddrPort]
 }
 
-func (mockRelayServer) Close() error { return nil }
-func (mockRelayServer) AllocateEndpoint(_, _ key.DiscoPublic) (endpoint.ServerEndpoint, error) {
+func (m *mockRelayServer) Close() error { return nil }
+func (m *mockRelayServer) AllocateEndpoint(_, _ key.DiscoPublic) (endpoint.ServerEndpoint, error) {
 	return endpoint.ServerEndpoint{}, errors.New("not implemented")
 }
-func (mockRelayServer) GetSessions() []status.ServerSession { return nil }
-func (mockRelayServer) SetDERPMapView(tailcfg.DERPMapView)  { return }
+func (m *mockRelayServer) GetSessions() []status.ServerSession { return nil }
+func (m *mockRelayServer) SetDERPMapView(tailcfg.DERPMapView)  { return }
+func (m *mockRelayServer) SetStaticAddrPorts(aps views.Slice[netip.AddrPort]) {
+	m.addrPorts = aps
+}
 
 type mockSafeBackend struct {
 	sys *tsd.System

+ 36 - 34
ipn/ipn_clone.go

@@ -64,46 +64,48 @@ func (src *Prefs) Clone() *Prefs {
 	if dst.RelayServerPort != nil {
 		dst.RelayServerPort = ptr.To(*src.RelayServerPort)
 	}
+	dst.RelayServerStaticEndpoints = append(src.RelayServerStaticEndpoints[:0:0], src.RelayServerStaticEndpoints...)
 	dst.Persist = src.Persist.Clone()
 	return dst
 }
 
 // A compilation failure here means this code must be regenerated, with the command at the top of this file.
 var _PrefsCloneNeedsRegeneration = Prefs(struct {
-	ControlURL             string
-	RouteAll               bool
-	ExitNodeID             tailcfg.StableNodeID
-	ExitNodeIP             netip.Addr
-	AutoExitNode           ExitNodeExpression
-	InternalExitNodePrior  tailcfg.StableNodeID
-	ExitNodeAllowLANAccess bool
-	CorpDNS                bool
-	RunSSH                 bool
-	RunWebClient           bool
-	WantRunning            bool
-	LoggedOut              bool
-	ShieldsUp              bool
-	AdvertiseTags          []string
-	Hostname               string
-	NotepadURLs            bool
-	ForceDaemon            bool
-	Egg                    bool
-	AdvertiseRoutes        []netip.Prefix
-	AdvertiseServices      []string
-	Sync                   opt.Bool
-	NoSNAT                 bool
-	NoStatefulFiltering    opt.Bool
-	NetfilterMode          preftype.NetfilterMode
-	OperatorUser           string
-	ProfileName            string
-	AutoUpdate             AutoUpdatePrefs
-	AppConnector           AppConnectorPrefs
-	PostureChecking        bool
-	NetfilterKind          string
-	DriveShares            []*drive.Share
-	RelayServerPort        *int
-	AllowSingleHosts       marshalAsTrueInJSON
-	Persist                *persist.Persist
+	ControlURL                 string
+	RouteAll                   bool
+	ExitNodeID                 tailcfg.StableNodeID
+	ExitNodeIP                 netip.Addr
+	AutoExitNode               ExitNodeExpression
+	InternalExitNodePrior      tailcfg.StableNodeID
+	ExitNodeAllowLANAccess     bool
+	CorpDNS                    bool
+	RunSSH                     bool
+	RunWebClient               bool
+	WantRunning                bool
+	LoggedOut                  bool
+	ShieldsUp                  bool
+	AdvertiseTags              []string
+	Hostname                   string
+	NotepadURLs                bool
+	ForceDaemon                bool
+	Egg                        bool
+	AdvertiseRoutes            []netip.Prefix
+	AdvertiseServices          []string
+	Sync                       opt.Bool
+	NoSNAT                     bool
+	NoStatefulFiltering        opt.Bool
+	NetfilterMode              preftype.NetfilterMode
+	OperatorUser               string
+	ProfileName                string
+	AutoUpdate                 AutoUpdatePrefs
+	AppConnector               AppConnectorPrefs
+	PostureChecking            bool
+	NetfilterKind              string
+	DriveShares                []*drive.Share
+	RelayServerPort            *int
+	RelayServerStaticEndpoints []netip.AddrPort
+	AllowSingleHosts           marshalAsTrueInJSON
+	Persist                    *persist.Persist
 }{})
 
 // Clone makes a deep copy of ServeConfig.

+ 42 - 34
ipn/ipn_view.go

@@ -448,6 +448,13 @@ func (v PrefsView) RelayServerPort() views.ValuePointer[int] {
 	return views.ValuePointerOf(v.ж.RelayServerPort)
 }
 
+// RelayServerStaticEndpoints are static IP:port endpoints to advertise as
+// candidates for relay connections. Only relevant when RelayServerPort is
+// non-nil.
+func (v PrefsView) RelayServerStaticEndpoints() views.Slice[netip.AddrPort] {
+	return views.SliceOf(v.ж.RelayServerStaticEndpoints)
+}
+
 // AllowSingleHosts was a legacy field that was always true
 // for the past 4.5 years. It controlled whether Tailscale
 // peers got /32 or /128 routes for each other.
@@ -468,40 +475,41 @@ func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() }
 
 // A compilation failure here means this code must be regenerated, with the command at the top of this file.
 var _PrefsViewNeedsRegeneration = Prefs(struct {
-	ControlURL             string
-	RouteAll               bool
-	ExitNodeID             tailcfg.StableNodeID
-	ExitNodeIP             netip.Addr
-	AutoExitNode           ExitNodeExpression
-	InternalExitNodePrior  tailcfg.StableNodeID
-	ExitNodeAllowLANAccess bool
-	CorpDNS                bool
-	RunSSH                 bool
-	RunWebClient           bool
-	WantRunning            bool
-	LoggedOut              bool
-	ShieldsUp              bool
-	AdvertiseTags          []string
-	Hostname               string
-	NotepadURLs            bool
-	ForceDaemon            bool
-	Egg                    bool
-	AdvertiseRoutes        []netip.Prefix
-	AdvertiseServices      []string
-	Sync                   opt.Bool
-	NoSNAT                 bool
-	NoStatefulFiltering    opt.Bool
-	NetfilterMode          preftype.NetfilterMode
-	OperatorUser           string
-	ProfileName            string
-	AutoUpdate             AutoUpdatePrefs
-	AppConnector           AppConnectorPrefs
-	PostureChecking        bool
-	NetfilterKind          string
-	DriveShares            []*drive.Share
-	RelayServerPort        *int
-	AllowSingleHosts       marshalAsTrueInJSON
-	Persist                *persist.Persist
+	ControlURL                 string
+	RouteAll                   bool
+	ExitNodeID                 tailcfg.StableNodeID
+	ExitNodeIP                 netip.Addr
+	AutoExitNode               ExitNodeExpression
+	InternalExitNodePrior      tailcfg.StableNodeID
+	ExitNodeAllowLANAccess     bool
+	CorpDNS                    bool
+	RunSSH                     bool
+	RunWebClient               bool
+	WantRunning                bool
+	LoggedOut                  bool
+	ShieldsUp                  bool
+	AdvertiseTags              []string
+	Hostname                   string
+	NotepadURLs                bool
+	ForceDaemon                bool
+	Egg                        bool
+	AdvertiseRoutes            []netip.Prefix
+	AdvertiseServices          []string
+	Sync                       opt.Bool
+	NoSNAT                     bool
+	NoStatefulFiltering        opt.Bool
+	NetfilterMode              preftype.NetfilterMode
+	OperatorUser               string
+	ProfileName                string
+	AutoUpdate                 AutoUpdatePrefs
+	AppConnector               AppConnectorPrefs
+	PostureChecking            bool
+	NetfilterKind              string
+	DriveShares                []*drive.Share
+	RelayServerPort            *int
+	RelayServerStaticEndpoints []netip.AddrPort
+	AllowSingleHosts           marshalAsTrueInJSON
+	Persist                    *persist.Persist
 }{})
 
 // View returns a read-only view of ServeConfig.

+ 43 - 33
ipn/prefs.go

@@ -288,6 +288,11 @@ type Prefs struct {
 	// non-nil/enabled.
 	RelayServerPort *int `json:",omitempty"`
 
+	// RelayServerStaticEndpoints are static IP:port endpoints to advertise as
+	// candidates for relay connections. Only relevant when RelayServerPort is
+	// non-nil.
+	RelayServerStaticEndpoints []netip.AddrPort `json:",omitempty"`
+
 	// AllowSingleHosts was a legacy field that was always true
 	// for the past 4.5 years. It controlled whether Tailscale
 	// peers got /32 or /128 routes for each other.
@@ -350,38 +355,39 @@ type AppConnectorPrefs struct {
 type MaskedPrefs struct {
 	Prefs
 
-	ControlURLSet             bool                `json:",omitempty"`
-	RouteAllSet               bool                `json:",omitempty"`
-	ExitNodeIDSet             bool                `json:",omitempty"`
-	ExitNodeIPSet             bool                `json:",omitempty"`
-	AutoExitNodeSet           bool                `json:",omitempty"`
-	InternalExitNodePriorSet  bool                `json:",omitempty"` // Internal; can't be set by LocalAPI clients
-	ExitNodeAllowLANAccessSet bool                `json:",omitempty"`
-	CorpDNSSet                bool                `json:",omitempty"`
-	RunSSHSet                 bool                `json:",omitempty"`
-	RunWebClientSet           bool                `json:",omitempty"`
-	WantRunningSet            bool                `json:",omitempty"`
-	LoggedOutSet              bool                `json:",omitempty"`
-	ShieldsUpSet              bool                `json:",omitempty"`
-	AdvertiseTagsSet          bool                `json:",omitempty"`
-	HostnameSet               bool                `json:",omitempty"`
-	NotepadURLsSet            bool                `json:",omitempty"`
-	ForceDaemonSet            bool                `json:",omitempty"`
-	EggSet                    bool                `json:",omitempty"`
-	AdvertiseRoutesSet        bool                `json:",omitempty"`
-	AdvertiseServicesSet      bool                `json:",omitempty"`
-	SyncSet                   bool                `json:",omitzero"`
-	NoSNATSet                 bool                `json:",omitempty"`
-	NoStatefulFilteringSet    bool                `json:",omitempty"`
-	NetfilterModeSet          bool                `json:",omitempty"`
-	OperatorUserSet           bool                `json:",omitempty"`
-	ProfileNameSet            bool                `json:",omitempty"`
-	AutoUpdateSet             AutoUpdatePrefsMask `json:",omitzero"`
-	AppConnectorSet           bool                `json:",omitempty"`
-	PostureCheckingSet        bool                `json:",omitempty"`
-	NetfilterKindSet          bool                `json:",omitempty"`
-	DriveSharesSet            bool                `json:",omitempty"`
-	RelayServerPortSet        bool                `json:",omitempty"`
+	ControlURLSet                 bool                `json:",omitempty"`
+	RouteAllSet                   bool                `json:",omitempty"`
+	ExitNodeIDSet                 bool                `json:",omitempty"`
+	ExitNodeIPSet                 bool                `json:",omitempty"`
+	AutoExitNodeSet               bool                `json:",omitempty"`
+	InternalExitNodePriorSet      bool                `json:",omitempty"` // Internal; can't be set by LocalAPI clients
+	ExitNodeAllowLANAccessSet     bool                `json:",omitempty"`
+	CorpDNSSet                    bool                `json:",omitempty"`
+	RunSSHSet                     bool                `json:",omitempty"`
+	RunWebClientSet               bool                `json:",omitempty"`
+	WantRunningSet                bool                `json:",omitempty"`
+	LoggedOutSet                  bool                `json:",omitempty"`
+	ShieldsUpSet                  bool                `json:",omitempty"`
+	AdvertiseTagsSet              bool                `json:",omitempty"`
+	HostnameSet                   bool                `json:",omitempty"`
+	NotepadURLsSet                bool                `json:",omitempty"`
+	ForceDaemonSet                bool                `json:",omitempty"`
+	EggSet                        bool                `json:",omitempty"`
+	AdvertiseRoutesSet            bool                `json:",omitempty"`
+	AdvertiseServicesSet          bool                `json:",omitempty"`
+	SyncSet                       bool                `json:",omitzero"`
+	NoSNATSet                     bool                `json:",omitempty"`
+	NoStatefulFilteringSet        bool                `json:",omitempty"`
+	NetfilterModeSet              bool                `json:",omitempty"`
+	OperatorUserSet               bool                `json:",omitempty"`
+	ProfileNameSet                bool                `json:",omitempty"`
+	AutoUpdateSet                 AutoUpdatePrefsMask `json:",omitzero"`
+	AppConnectorSet               bool                `json:",omitempty"`
+	PostureCheckingSet            bool                `json:",omitempty"`
+	NetfilterKindSet              bool                `json:",omitempty"`
+	DriveSharesSet                bool                `json:",omitempty"`
+	RelayServerPortSet            bool                `json:",omitempty"`
+	RelayServerStaticEndpointsSet bool                `json:",omitzero"`
 }
 
 // SetsInternal reports whether mp has any of the Internal*Set field bools set
@@ -621,6 +627,9 @@ func (p *Prefs) pretty(goos string) string {
 	if buildfeatures.HasRelayServer && p.RelayServerPort != nil {
 		fmt.Fprintf(&sb, "relayServerPort=%d ", *p.RelayServerPort)
 	}
+	if buildfeatures.HasRelayServer && len(p.RelayServerStaticEndpoints) > 0 {
+		fmt.Fprintf(&sb, "relayServerStaticEndpoints=%v ", p.RelayServerStaticEndpoints)
+	}
 	if p.Persist != nil {
 		sb.WriteString(p.Persist.Pretty())
 	} else {
@@ -685,7 +694,8 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
 		p.PostureChecking == p2.PostureChecking &&
 		slices.EqualFunc(p.DriveShares, p2.DriveShares, drive.SharesEqual) &&
 		p.NetfilterKind == p2.NetfilterKind &&
-		compareIntPtrs(p.RelayServerPort, p2.RelayServerPort)
+		compareIntPtrs(p.RelayServerPort, p2.RelayServerPort) &&
+		slices.Equal(p.RelayServerStaticEndpoints, p2.RelayServerStaticEndpoints)
 }
 
 func (au AutoUpdatePrefs) Pretty() string {

+ 21 - 0
ipn/prefs_test.go

@@ -69,6 +69,7 @@ func TestPrefsEqual(t *testing.T) {
 		"NetfilterKind",
 		"DriveShares",
 		"RelayServerPort",
+		"RelayServerStaticEndpoints",
 		"AllowSingleHosts",
 		"Persist",
 	}
@@ -90,6 +91,16 @@ func TestPrefsEqual(t *testing.T) {
 		}
 		return ns
 	}
+	aps := func(strs ...string) (ret []netip.AddrPort) {
+		for _, s := range strs {
+			n, err := netip.ParseAddrPort(s)
+			if err != nil {
+				panic(err)
+			}
+			ret = append(ret, n)
+		}
+		return ret
+	}
 	tests := []struct {
 		a, b *Prefs
 		want bool
@@ -369,6 +380,16 @@ func TestPrefsEqual(t *testing.T) {
 			&Prefs{RelayServerPort: relayServerPort(1)},
 			false,
 		},
+		{
+			&Prefs{RelayServerStaticEndpoints: aps("[2001:db8::1]:40000", "192.0.2.1:40000")},
+			&Prefs{RelayServerStaticEndpoints: aps("[2001:db8::1]:40000", "192.0.2.1:40000")},
+			true,
+		},
+		{
+			&Prefs{RelayServerStaticEndpoints: aps("[2001:db8::1]:40000", "192.0.2.2:40000")},
+			&Prefs{RelayServerStaticEndpoints: aps("[2001:db8::1]:40000", "192.0.2.1:40000")},
+			false,
+		},
 	}
 	for i, tt := range tests {
 		got := tt.a.Equals(tt.b)