diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 4c58ae8ec..0af40cfc7 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -11,6 +11,7 @@ import ( "context" "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -5017,13 +5018,7 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip } hi.SSH_HostKeys = sshHostKeys - services := vipServicesFromPrefs(prefs) - if len(services) > 0 { - buf, _ := json.Marshal(services) - hi.ServicesHash = fmt.Sprintf("%02x", sha256.Sum256(buf)) - } else { - hi.ServicesHash = "" - } + hi.ServicesHash = b.vipServiceHashLocked(prefs) // The Hostinfo.WantIngress field tells control whether this node wants to // be wired up for ingress connections. If harmless if it's accidentally @@ -7659,28 +7654,38 @@ func maybeUsernameOf(actor ipnauth.Actor) string { func (b *LocalBackend) VIPServices() []*tailcfg.VIPService { b.mu.Lock() defer b.mu.Unlock() - return vipServicesFromPrefs(b.pm.CurrentPrefs()) + return b.vipServicesFromPrefsLocked(b.pm.CurrentPrefs()) } -func vipServicesFromPrefs(prefs ipn.PrefsView) []*tailcfg.VIPService { +func (b *LocalBackend) vipServiceHashLocked(prefs ipn.PrefsView) string { + services := b.vipServicesFromPrefsLocked(prefs) + if len(services) == 0 { + return "" + } + buf, err := json.Marshal(services) + if err != nil { + b.logf("vipServiceHashLocked: %v", err) + return "" + } + hash := sha256.Sum256(buf) + return hex.EncodeToString(hash[:]) +} + +func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService { // keyed by service name var services map[string]*tailcfg.VIPService - - // TODO(naman): this envknob will be replaced with service-specific port - // information once we start storing that. - var allPortsServices []string - if env := envknob.String("TS_DEBUG_ALLPORTS_SERVICES"); env != "" { - allPortsServices = strings.Split(env, ",") + if !b.serveConfig.Valid() { + return nil } - for _, s := range allPortsServices { - mak.Set(&services, s, &tailcfg.VIPService{ - Name: s, - Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}}, + for svc, config := range b.serveConfig.Services().All() { + mak.Set(&services, svc, &tailcfg.VIPService{ + Name: svc, + Ports: config.ServicePortRange(), }) } - for _, s := range prefs.AdvertiseServices().AsSlice() { + for _, s := range prefs.AdvertiseServices().All() { if services == nil || services[s] == nil { mak.Set(&services, s, &tailcfg.VIPService{ Name: s, diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 15766741b..f3ee24a6b 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -30,7 +30,6 @@ import ( "tailscale.com/control/controlclient" "tailscale.com/drive" "tailscale.com/drive/driveimpl" - "tailscale.com/envknob" "tailscale.com/health" "tailscale.com/hostinfo" "tailscale.com/ipn" @@ -4509,15 +4508,15 @@ func TestConfigFileReload(t *testing.T) { func TestGetVIPServices(t *testing.T) { tests := []struct { - name string - advertised []string - mapped []string - want []*tailcfg.VIPService + name string + advertised []string + serveConfig *ipn.ServeConfig + want []*tailcfg.VIPService }{ { "advertised-only", []string{"svc:abc", "svc:def"}, - []string{}, + &ipn.ServeConfig{}, []*tailcfg.VIPService{ { Name: "svc:abc", @@ -4530,9 +4529,13 @@ func TestGetVIPServices(t *testing.T) { }, }, { - "mapped-only", + "served-only", []string{}, - []string{"svc:abc"}, + &ipn.ServeConfig{ + Services: map[string]*ipn.ServiceConfig{ + "svc:abc": {Tun: true}, + }, + }, []*tailcfg.VIPService{ { Name: "svc:abc", @@ -4541,9 +4544,13 @@ func TestGetVIPServices(t *testing.T) { }, }, { - "mapped-and-advertised", - []string{"svc:abc"}, + "served-and-advertised", []string{"svc:abc"}, + &ipn.ServeConfig{ + Services: map[string]*ipn.ServiceConfig{ + "svc:abc": {Tun: true}, + }, + }, []*tailcfg.VIPService{ { Name: "svc:abc", @@ -4553,9 +4560,13 @@ func TestGetVIPServices(t *testing.T) { }, }, { - "mapped-and-advertised-separately", + "served-and-advertised-different-service", []string{"svc:def"}, - []string{"svc:abc"}, + &ipn.ServeConfig{ + Services: map[string]*ipn.ServiceConfig{ + "svc:abc": {Tun: true}, + }, + }, []*tailcfg.VIPService{ { Name: "svc:abc", @@ -4567,14 +4578,78 @@ func TestGetVIPServices(t *testing.T) { }, }, }, + { + "served-with-port-ranges-one-range-single", + []string{}, + &ipn.ServeConfig{ + Services: map[string]*ipn.ServiceConfig{ + "svc:abc": {TCP: map[uint16]*ipn.TCPPortHandler{ + 80: {HTTPS: true}, + }}, + }, + }, + []*tailcfg.VIPService{ + { + Name: "svc:abc", + Ports: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 80, Last: 80}}}, + }, + }, + }, + { + "served-with-port-ranges-one-range-multiple", + []string{}, + &ipn.ServeConfig{ + Services: map[string]*ipn.ServiceConfig{ + "svc:abc": {TCP: map[uint16]*ipn.TCPPortHandler{ + 80: {HTTPS: true}, + 81: {HTTPS: true}, + 82: {HTTPS: true}, + }}, + }, + }, + []*tailcfg.VIPService{ + { + Name: "svc:abc", + Ports: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 80, Last: 82}}}, + }, + }, + }, + { + "served-with-port-ranges-multiple-ranges", + []string{}, + &ipn.ServeConfig{ + Services: map[string]*ipn.ServiceConfig{ + "svc:abc": {TCP: map[uint16]*ipn.TCPPortHandler{ + 80: {HTTPS: true}, + 81: {HTTPS: true}, + 82: {HTTPS: true}, + 1212: {HTTPS: true}, + 1213: {HTTPS: true}, + 1214: {HTTPS: true}, + }}, + }, + }, + []*tailcfg.VIPService{ + { + Name: "svc:abc", + Ports: []tailcfg.ProtoPortRange{ + {Proto: 6, Ports: tailcfg.PortRange{First: 80, Last: 82}}, + {Proto: 6, Ports: tailcfg.PortRange{First: 1212, Last: 1214}}, + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - envknob.Setenv("TS_DEBUG_ALLPORTS_SERVICES", strings.Join(tt.mapped, ",")) + lb := newLocalBackendWithTestControl(t, false, func(tb testing.TB, opts controlclient.Options) controlclient.Client { + return newClient(tb, opts) + }) + lb.serveConfig = tt.serveConfig.View() prefs := &ipn.Prefs{ AdvertiseServices: tt.advertised, } - got := vipServicesFromPrefs(prefs.View()) + got := lb.vipServicesFromPrefsLocked(prefs.View()) slices.SortFunc(got, func(a, b *tailcfg.VIPService) int { return strings.Compare(a.Name, b.Name) }) diff --git a/ipn/serve.go b/ipn/serve.go index e82279db8..b7effa874 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -16,7 +16,9 @@ import ( "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" + "tailscale.com/types/ipproto" "tailscale.com/util/mak" + "tailscale.com/util/set" ) // ServeConfigKey returns a StateKey that stores the @@ -655,3 +657,41 @@ func (v ServeConfigView) HasFunnelForTarget(target HostPort) bool { } return false } + +// ServicePortRange returns the list of tailcfg.ProtoPortRange that represents +// the proto/ports pairs that are being served by the service. +// +// Right now Tun mode is the only thing supports UDP, otherwise serve only supports TCP. +func (v ServiceConfigView) ServicePortRange() []tailcfg.ProtoPortRange { + if v.Tun() { + // If the service is in Tun mode, means service accept TCP/UDP on all ports. + return []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}} + } + tcp := int(ipproto.TCP) + + // Deduplicate the ports. + servePorts := make(set.Set[uint16]) + for port := range v.TCP().All() { + if port > 0 { + servePorts.Add(uint16(port)) + } + } + dedupedServePorts := servePorts.Slice() + slices.Sort(dedupedServePorts) + + var ranges []tailcfg.ProtoPortRange + for _, p := range dedupedServePorts { + if n := len(ranges); n > 0 && p == ranges[n-1].Ports.Last+1 { + ranges[n-1].Ports.Last = p + continue + } + ranges = append(ranges, tailcfg.ProtoPortRange{ + Proto: tcp, + Ports: tailcfg.PortRange{ + First: p, + Last: p, + }, + }) + } + return ranges +}