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 <mail@nsood.in>

* more review fixes

Signed-off-by: Naman Sood <mail@nsood.in>

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

Signed-off-by: Naman Sood <mail@nsood.in>

---------

Signed-off-by: Naman Sood <mail@nsood.in>
This commit is contained in:
Naman Sood 2024-11-15 16:14:06 -05:00 committed by GitHub
parent 1355f622be
commit aefbed323f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 187 additions and 1 deletions

View File

@ -77,6 +77,9 @@
// Linux netfilter. // Linux netfilter.
req("POST /netfilter-kind"): handleC2NSetNetfilterKind, req("POST /netfilter-kind"): handleC2NSetNetfilterKind,
// VIP services.
req("GET /vip-services"): handleC2NVIPServicesGet,
} }
type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request) 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) 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) { func handleC2NUpdateGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /update received") b.logf("c2n: GET /update received")

View File

@ -9,6 +9,7 @@
"bytes" "bytes"
"cmp" "cmp"
"context" "context"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
@ -4888,6 +4889,14 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip
} }
hi.SSH_HostKeys = sshHostKeys 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 // The Hostinfo.WantIngress field tells control whether this node wants to
// be wired up for ingress connections. If harmless if it's accidentally // be wired up for ingress connections. If harmless if it's accidentally
// true; the actual policy is controlled in tailscaled by ServeConfig. But // true; the actual policy is controlled in tailscaled by ServeConfig. But
@ -7485,3 +7494,42 @@ func maybeUsernameOf(actor ipnauth.Actor) string {
} }
return username 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))
}

View File

@ -30,6 +30,7 @@
"tailscale.com/control/controlclient" "tailscale.com/control/controlclient"
"tailscale.com/drive" "tailscale.com/drive"
"tailscale.com/drive/driveimpl" "tailscale.com/drive/driveimpl"
"tailscale.com/envknob"
"tailscale.com/health" "tailscale.com/health"
"tailscale.com/hostinfo" "tailscale.com/hostinfo"
"tailscale.com/ipn" "tailscale.com/ipn"
@ -4464,3 +4465,90 @@ func TestConfigFileReload(t *testing.T) {
t.Fatalf("got %q; want %q", hn, "bar") 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
}
})
}
}

View File

@ -150,7 +150,8 @@
// - 105: 2024-08-05: Fixed SSH behavior on systems that use busybox (issue #12849) // - 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) // - 106: 2024-09-03: fix panic regression from cryptokey routing change (65fe0ba7b5)
// - 107: 2024-10-30: add App Connector to conffile (PR #13942) // - 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 type StableID string
@ -820,6 +821,7 @@ type Hostinfo struct {
Userspace opt.Bool `json:",omitempty"` // if the client is running in userspace (netstack) mode 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 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 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 // Location represents geographical location data about a
// Tailscale host. Location is optional and only set if // Tailscale host. Location is optional and only set if
@ -830,6 +832,26 @@ type Hostinfo struct {
// require changes to Hostinfo.Equal. // 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 // TailscaleSSHEnabled reports whether or not this node is acting as a
// Tailscale SSH server. // Tailscale SSH server.
func (hi *Hostinfo) TailscaleSSHEnabled() bool { func (hi *Hostinfo) TailscaleSSHEnabled() bool {
@ -1429,6 +1451,11 @@ type CapGrant struct {
// user groups as Kubernetes user groups. This capability is read by // user groups as Kubernetes user groups. This capability is read by
// peers that are Tailscale Kubernetes operator instances. // peers that are Tailscale Kubernetes operator instances.
PeerCapabilityKubernetes PeerCapability = "tailscale.com/cap/kubernetes" 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 // NodeCapMap is a map of capabilities to their optional values. It is valid for

View File

@ -183,6 +183,7 @@ func (src *Hostinfo) Clone() *Hostinfo {
Userspace opt.Bool Userspace opt.Bool
UserspaceRouter opt.Bool UserspaceRouter opt.Bool
AppConnector opt.Bool AppConnector opt.Bool
ServicesHash string
Location *Location Location *Location
}{}) }{})

View File

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

View File

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