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

Updates tailscale/corp#31489
Updates #17791

Signed-off-by: Jordan Whited <jordan@tailscale.com>
jwhited/relay-set-flags-config
Jordan Whited 2 weeks ago committed by Jordan Whited
parent 755309c04e
commit 7426eca163

@ -11,6 +11,7 @@ import (
"net/netip" "net/netip"
"os/exec" "os/exec"
"runtime" "runtime"
"slices"
"strconv" "strconv"
"strings" "strings"
@ -25,6 +26,7 @@ import (
"tailscale.com/types/opt" "tailscale.com/types/opt"
"tailscale.com/types/ptr" "tailscale.com/types/ptr"
"tailscale.com/types/views" "tailscale.com/types/views"
"tailscale.com/util/set"
"tailscale.com/version" "tailscale.com/version"
) )
@ -66,6 +68,7 @@ type setArgsT struct {
sync bool sync bool
netfilterMode string netfilterMode string
relayServerPort string relayServerPort string
relayServerStaticEndpoints string
} }
func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet { 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.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.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.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) { ffcomplete.Flag(setf, "exit-node", func(args []string) ([]string, ffcomplete.ShellCompDirective, error) {
st, err := localClient.Status(context.Background()) 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)) 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 := curPrefs.Clone()
checkPrefs.ApplyEdits(maskedPrefs) checkPrefs.ApplyEdits(maskedPrefs)
if err := localClient.CheckPrefs(ctx, checkPrefs); err != nil { if err := localClient.CheckPrefs(ctx, checkPrefs); err != nil {

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

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

@ -5,7 +5,9 @@ package relayserver
import ( import (
"errors" "errors"
"net/netip"
"reflect" "reflect"
"slices"
"testing" "testing"
"tailscale.com/ipn" "tailscale.com/ipn"
@ -17,14 +19,20 @@ import (
"tailscale.com/types/key" "tailscale.com/types/key"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/types/ptr" "tailscale.com/types/ptr"
"tailscale.com/types/views"
) )
func Test_extension_profileStateChanged(t *testing.T) { func Test_extension_profileStateChanged(t *testing.T) {
prefsWithPortOne := ipn.Prefs{RelayServerPort: ptr.To(1)} prefsWithPortOne := ipn.Prefs{RelayServerPort: ptr.To(1)}
prefsWithNilPort := ipn.Prefs{RelayServerPort: nil} 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 { type fields struct {
port *int port *int
staticEndpoints views.Slice[netip.AddrPort]
rs relayServer rs relayServer
} }
type args struct { type args struct {
@ -38,6 +46,7 @@ func Test_extension_profileStateChanged(t *testing.T) {
wantPort *int wantPort *int
wantRelayServerFieldNonNil bool wantRelayServerFieldNonNil bool
wantRelayServerFieldMutated bool wantRelayServerFieldMutated bool
wantEndpoints []netip.AddrPort
}{ }{
{ {
name: "no changes non-nil port previously running", name: "no changes non-nil port previously running",
@ -53,6 +62,52 @@ func Test_extension_profileStateChanged(t *testing.T) {
wantRelayServerFieldNonNil: true, wantRelayServerFieldNonNil: true,
wantRelayServerFieldMutated: false, 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", name: "prefs port nil",
fields: fields{ fields: fields{
@ -160,6 +215,7 @@ func Test_extension_profileStateChanged(t *testing.T) {
return &mockRelayServer{}, nil return &mockRelayServer{}, nil
} }
e.port = tt.fields.port e.port = tt.fields.port
e.staticEndpoints = tt.fields.staticEndpoints
e.rs = tt.fields.rs e.rs = tt.fields.rs
defer e.Shutdown() defer e.Shutdown()
e.profileStateChanged(ipn.LoginProfileView{}, tt.args.prefs, tt.args.sameNode) 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) { 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)) 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 { func mockRelayServerNotZeroVal() *mockRelayServer {
return &mockRelayServer{true} return &mockRelayServer{set: true}
} }
type mockRelayServer struct { type mockRelayServer struct {
set bool set bool
addrPorts views.Slice[netip.AddrPort]
} }
func (mockRelayServer) Close() error { return nil } func (m *mockRelayServer) Close() error { return nil }
func (mockRelayServer) AllocateEndpoint(_, _ key.DiscoPublic) (endpoint.ServerEndpoint, error) { func (m *mockRelayServer) AllocateEndpoint(_, _ key.DiscoPublic) (endpoint.ServerEndpoint, error) {
return endpoint.ServerEndpoint{}, errors.New("not implemented") return endpoint.ServerEndpoint{}, errors.New("not implemented")
} }
func (mockRelayServer) GetSessions() []status.ServerSession { return nil } func (m *mockRelayServer) GetSessions() []status.ServerSession { return nil }
func (mockRelayServer) SetDERPMapView(tailcfg.DERPMapView) { return } func (m *mockRelayServer) SetDERPMapView(tailcfg.DERPMapView) { return }
func (m *mockRelayServer) SetStaticAddrPorts(aps views.Slice[netip.AddrPort]) {
m.addrPorts = aps
}
type mockSafeBackend struct { type mockSafeBackend struct {
sys *tsd.System sys *tsd.System

@ -64,6 +64,7 @@ func (src *Prefs) Clone() *Prefs {
if dst.RelayServerPort != nil { if dst.RelayServerPort != nil {
dst.RelayServerPort = ptr.To(*src.RelayServerPort) dst.RelayServerPort = ptr.To(*src.RelayServerPort)
} }
dst.RelayServerStaticEndpoints = append(src.RelayServerStaticEndpoints[:0:0], src.RelayServerStaticEndpoints...)
dst.Persist = src.Persist.Clone() dst.Persist = src.Persist.Clone()
return dst return dst
} }
@ -102,6 +103,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
NetfilterKind string NetfilterKind string
DriveShares []*drive.Share DriveShares []*drive.Share
RelayServerPort *int RelayServerPort *int
RelayServerStaticEndpoints []netip.AddrPort
AllowSingleHosts marshalAsTrueInJSON AllowSingleHosts marshalAsTrueInJSON
Persist *persist.Persist Persist *persist.Persist
}{}) }{})

@ -448,6 +448,13 @@ func (v PrefsView) RelayServerPort() views.ValuePointer[int] {
return views.ValuePointerOf(v.ж.RelayServerPort) 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 // AllowSingleHosts was a legacy field that was always true
// for the past 4.5 years. It controlled whether Tailscale // for the past 4.5 years. It controlled whether Tailscale
// peers got /32 or /128 routes for each other. // peers got /32 or /128 routes for each other.
@ -500,6 +507,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
NetfilterKind string NetfilterKind string
DriveShares []*drive.Share DriveShares []*drive.Share
RelayServerPort *int RelayServerPort *int
RelayServerStaticEndpoints []netip.AddrPort
AllowSingleHosts marshalAsTrueInJSON AllowSingleHosts marshalAsTrueInJSON
Persist *persist.Persist Persist *persist.Persist
}{}) }{})

@ -288,6 +288,11 @@ type Prefs struct {
// non-nil/enabled. // non-nil/enabled.
RelayServerPort *int `json:",omitempty"` 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 // AllowSingleHosts was a legacy field that was always true
// for the past 4.5 years. It controlled whether Tailscale // for the past 4.5 years. It controlled whether Tailscale
// peers got /32 or /128 routes for each other. // peers got /32 or /128 routes for each other.
@ -382,6 +387,7 @@ type MaskedPrefs struct {
NetfilterKindSet bool `json:",omitempty"` NetfilterKindSet bool `json:",omitempty"`
DriveSharesSet bool `json:",omitempty"` DriveSharesSet bool `json:",omitempty"`
RelayServerPortSet bool `json:",omitempty"` RelayServerPortSet bool `json:",omitempty"`
RelayServerStaticEndpointsSet bool `json:",omitzero"`
} }
// SetsInternal reports whether mp has any of the Internal*Set field bools set // 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 { if buildfeatures.HasRelayServer && p.RelayServerPort != nil {
fmt.Fprintf(&sb, "relayServerPort=%d ", *p.RelayServerPort) 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 { if p.Persist != nil {
sb.WriteString(p.Persist.Pretty()) sb.WriteString(p.Persist.Pretty())
} else { } else {
@ -685,7 +694,8 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.PostureChecking == p2.PostureChecking && p.PostureChecking == p2.PostureChecking &&
slices.EqualFunc(p.DriveShares, p2.DriveShares, drive.SharesEqual) && slices.EqualFunc(p.DriveShares, p2.DriveShares, drive.SharesEqual) &&
p.NetfilterKind == p2.NetfilterKind && 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 { func (au AutoUpdatePrefs) Pretty() string {

@ -69,6 +69,7 @@ func TestPrefsEqual(t *testing.T) {
"NetfilterKind", "NetfilterKind",
"DriveShares", "DriveShares",
"RelayServerPort", "RelayServerPort",
"RelayServerStaticEndpoints",
"AllowSingleHosts", "AllowSingleHosts",
"Persist", "Persist",
} }
@ -90,6 +91,16 @@ func TestPrefsEqual(t *testing.T) {
} }
return ns 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 { tests := []struct {
a, b *Prefs a, b *Prefs
want bool want bool
@ -369,6 +380,16 @@ func TestPrefsEqual(t *testing.T) {
&Prefs{RelayServerPort: relayServerPort(1)}, &Prefs{RelayServerPort: relayServerPort(1)},
false, 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 { for i, tt := range tests {
got := tt.a.Equals(tt.b) got := tt.a.Equals(tt.b)

Loading…
Cancel
Save