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

ipn,tailcfg: add VIPService struct and c2n to fetch them from client (#14046)

* ipn,tailcfg: add VIPService struct and c2n to fetch them from client

Updates tailscale/corp#22743, tailscale/corp#22955

Signed-off-by: Naman Sood <[email protected]>

* more review fixes

Signed-off-by: Naman Sood <[email protected]>

* don't mention PeerCapabilityServicesDestination since it's currently unused

Signed-off-by: Naman Sood <[email protected]>

---------

Signed-off-by: Naman Sood <[email protected]>
Naman Sood 1 год назад
Родитель
Сommit
aefbed323f

+ 9 - 0
ipn/ipnlocal/c2n.go

@@ -77,6 +77,9 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
 
 	// Linux netfilter.
 	req("POST /netfilter-kind"): handleC2NSetNetfilterKind,
+
+	// VIP services.
+	req("GET /vip-services"): handleC2NVIPServicesGet,
 }
 
 type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
@@ -269,6 +272,12 @@ func handleC2NSetNetfilterKind(b *LocalBackend, w http.ResponseWriter, r *http.R
 	w.WriteHeader(http.StatusNoContent)
 }
 
+func handleC2NVIPServicesGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
+	b.logf("c2n: GET /vip-services received")
+
+	json.NewEncoder(w).Encode(b.VIPServices())
+}
+
 func handleC2NUpdateGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
 	b.logf("c2n: GET /update received")
 

+ 48 - 0
ipn/ipnlocal/local.go

@@ -9,6 +9,7 @@ import (
 	"bytes"
 	"cmp"
 	"context"
+	"crypto/sha256"
 	"encoding/base64"
 	"encoding/json"
 	"errors"
@@ -4888,6 +4889,14 @@ 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 = ""
+	}
+
 	// The Hostinfo.WantIngress field tells control whether this node wants to
 	// be wired up for ingress connections. If harmless if it's accidentally
 	// true; the actual policy is controlled in tailscaled by ServeConfig. But
@@ -7485,3 +7494,42 @@ func maybeUsernameOf(actor ipnauth.Actor) string {
 	}
 	return username
 }
+
+// VIPServices returns the list of tailnet services that this node
+// is serving as a destination for.
+// The returned memory is owned by the caller.
+func (b *LocalBackend) VIPServices() []*tailcfg.VIPService {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	return vipServicesFromPrefs(b.pm.CurrentPrefs())
+}
+
+func vipServicesFromPrefs(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, ",")
+	}
+
+	for _, s := range allPortsServices {
+		mak.Set(&services, s, &tailcfg.VIPService{
+			Name:  s,
+			Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
+		})
+	}
+
+	for _, s := range prefs.AdvertiseServices().AsSlice() {
+		if services == nil || services[s] == nil {
+			mak.Set(&services, s, &tailcfg.VIPService{
+				Name: s,
+			})
+		}
+		services[s].Active = true
+	}
+
+	return slices.Collect(maps.Values(services))
+}

+ 88 - 0
ipn/ipnlocal/local_test.go

@@ -30,6 +30,7 @@ import (
 	"tailscale.com/control/controlclient"
 	"tailscale.com/drive"
 	"tailscale.com/drive/driveimpl"
+	"tailscale.com/envknob"
 	"tailscale.com/health"
 	"tailscale.com/hostinfo"
 	"tailscale.com/ipn"
@@ -4464,3 +4465,90 @@ func TestConfigFileReload(t *testing.T) {
 		t.Fatalf("got %q; want %q", hn, "bar")
 	}
 }
+
+func TestGetVIPServices(t *testing.T) {
+	tests := []struct {
+		name       string
+		advertised []string
+		mapped     []string
+		want       []*tailcfg.VIPService
+	}{
+		{
+			"advertised-only",
+			[]string{"svc:abc", "svc:def"},
+			[]string{},
+			[]*tailcfg.VIPService{
+				{
+					Name:   "svc:abc",
+					Active: true,
+				},
+				{
+					Name:   "svc:def",
+					Active: true,
+				},
+			},
+		},
+		{
+			"mapped-only",
+			[]string{},
+			[]string{"svc:abc"},
+			[]*tailcfg.VIPService{
+				{
+					Name:  "svc:abc",
+					Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
+				},
+			},
+		},
+		{
+			"mapped-and-advertised",
+			[]string{"svc:abc"},
+			[]string{"svc:abc"},
+			[]*tailcfg.VIPService{
+				{
+					Name:   "svc:abc",
+					Active: true,
+					Ports:  []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
+				},
+			},
+		},
+		{
+			"mapped-and-advertised-separately",
+			[]string{"svc:def"},
+			[]string{"svc:abc"},
+			[]*tailcfg.VIPService{
+				{
+					Name:  "svc:abc",
+					Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}},
+				},
+				{
+					Name:   "svc:def",
+					Active: true,
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			envknob.Setenv("TS_DEBUG_ALLPORTS_SERVICES", strings.Join(tt.mapped, ","))
+			prefs := &ipn.Prefs{
+				AdvertiseServices: tt.advertised,
+			}
+			got := vipServicesFromPrefs(prefs.View())
+			slices.SortFunc(got, func(a, b *tailcfg.VIPService) int {
+				return strings.Compare(a.Name, b.Name)
+			})
+			if !reflect.DeepEqual(tt.want, got) {
+				t.Logf("want:")
+				for _, s := range tt.want {
+					t.Logf("%+v", s)
+				}
+				t.Logf("got:")
+				for _, s := range got {
+					t.Logf("%+v", s)
+				}
+				t.Fail()
+				return
+			}
+		})
+	}
+}

+ 28 - 1
tailcfg/tailcfg.go

@@ -150,7 +150,8 @@ type CapabilityVersion int
 //   - 105: 2024-08-05: Fixed SSH behavior on systems that use busybox (issue #12849)
 //   - 106: 2024-09-03: fix panic regression from cryptokey routing change (65fe0ba7b5)
 //   - 107: 2024-10-30: add App Connector to conffile (PR #13942)
-const CurrentCapabilityVersion CapabilityVersion = 107
+//   - 108: 2024-11-08: Client sends ServicesHash in Hostinfo, understands c2n GET /vip-services.
+const CurrentCapabilityVersion CapabilityVersion = 108
 
 type StableID string
 
@@ -820,6 +821,7 @@ type Hostinfo struct {
 	Userspace       opt.Bool       `json:",omitempty"` // if the client is running in userspace (netstack) mode
 	UserspaceRouter opt.Bool       `json:",omitempty"` // if the client's subnet router is running in userspace (netstack) mode
 	AppConnector    opt.Bool       `json:",omitempty"` // if the client is running the app-connector service
+	ServicesHash    string         `json:",omitempty"` // opaque hash of the most recent list of tailnet services, change in hash indicates config should be fetched via c2n
 
 	// Location represents geographical location data about a
 	// Tailscale host. Location is optional and only set if
@@ -830,6 +832,26 @@ type Hostinfo struct {
 	//       require changes to Hostinfo.Equal.
 }
 
+// VIPService represents a service created on a tailnet from the
+// perspective of a node providing that service. These services
+// have an virtual IP (VIP) address pair distinct from the node's IPs.
+type VIPService struct {
+	// Name is the name of the service, of the form `svc:dns-label`.
+	// See CheckServiceName for a validation func.
+	// Name uniquely identifies a service on a particular tailnet,
+	// and so also corresponds uniquely to the pair of IP addresses
+	// belonging to the VIP service.
+	Name string
+
+	// Ports specify which ProtoPorts are made available by this node
+	// on the service's IPs.
+	Ports []ProtoPortRange
+
+	// Active specifies whether new requests for the service should be
+	// sent to this node by control.
+	Active bool
+}
+
 // TailscaleSSHEnabled reports whether or not this node is acting as a
 // Tailscale SSH server.
 func (hi *Hostinfo) TailscaleSSHEnabled() bool {
@@ -1429,6 +1451,11 @@ const (
 	// user groups as Kubernetes user groups. This capability is read by
 	// peers that are Tailscale Kubernetes operator instances.
 	PeerCapabilityKubernetes PeerCapability = "tailscale.com/cap/kubernetes"
+
+	// PeerCapabilityServicesDestination grants a peer the ability to serve as
+	// a destination for a set of given VIP services, which is provided as the
+	// value of this key in NodeCapMap.
+	PeerCapabilityServicesDestination PeerCapability = "tailscale.com/cap/services-destination"
 )
 
 // NodeCapMap is a map of capabilities to their optional values. It is valid for

+ 1 - 0
tailcfg/tailcfg_clone.go

@@ -183,6 +183,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct {
 	Userspace       opt.Bool
 	UserspaceRouter opt.Bool
 	AppConnector    opt.Bool
+	ServicesHash    string
 	Location        *Location
 }{})
 

+ 11 - 0
tailcfg/tailcfg_test.go

@@ -66,6 +66,7 @@ func TestHostinfoEqual(t *testing.T) {
 		"Userspace",
 		"UserspaceRouter",
 		"AppConnector",
+		"ServicesHash",
 		"Location",
 	}
 	if have := fieldsOf(reflect.TypeFor[Hostinfo]()); !reflect.DeepEqual(have, hiHandles) {
@@ -240,6 +241,16 @@ func TestHostinfoEqual(t *testing.T) {
 			&Hostinfo{AppConnector: opt.Bool("false")},
 			false,
 		},
+		{
+			&Hostinfo{ServicesHash: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"},
+			&Hostinfo{ServicesHash: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"},
+			true,
+		},
+		{
+			&Hostinfo{ServicesHash: "084c799cd551dd1d8d5c5f9a5d593b2e931f5e36122ee5c793c1d08a19839cc0"},
+			&Hostinfo{},
+			false,
+		},
 	}
 	for i, tt := range tests {
 		got := tt.a.Equal(tt.b)

+ 2 - 0
tailcfg/tailcfg_view.go

@@ -318,6 +318,7 @@ func (v HostinfoView) Cloud() string                          { return v.ж.Clou
 func (v HostinfoView) Userspace() opt.Bool                    { return v.ж.Userspace }
 func (v HostinfoView) UserspaceRouter() opt.Bool              { return v.ж.UserspaceRouter }
 func (v HostinfoView) AppConnector() opt.Bool                 { return v.ж.AppConnector }
+func (v HostinfoView) ServicesHash() string                   { return v.ж.ServicesHash }
 func (v HostinfoView) Location() *Location {
 	if v.ж.Location == nil {
 		return nil
@@ -365,6 +366,7 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct {
 	Userspace       opt.Bool
 	UserspaceRouter opt.Bool
 	AppConnector    opt.Bool
+	ServicesHash    string
 	Location        *Location
 }{})