diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index 8380689d1..f3a4a3a3d 100644 --- a/ipn/ipnlocal/c2n.go +++ b/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") diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 493762fcc..3c7296038 100644 --- a/ipn/ipnlocal/local.go +++ b/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)) +} diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 6dad2dba4..6d25a418f 100644 --- a/ipn/ipnlocal/local_test.go +++ b/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 + } + }) + } +} diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 9e39a4336..1b283a2fc 100644 --- a/tailcfg/tailcfg.go +++ b/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 diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index 61564f3f8..f4f02c017 100644 --- a/tailcfg/tailcfg_clone.go +++ b/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 }{}) diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index 0d0636677..9f8c418a1 100644 --- a/tailcfg/tailcfg_test.go +++ b/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) diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index a3e19b0dc..f275a6a9d 100644 --- a/tailcfg/tailcfg_view.go +++ b/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 }{})