appc,feature/conn25: conn25: send address assignments to connector

After we intercept a DNS response and assign magic and transit addresses
we must communicate the assignment to our connector so that it can
direct traffic when it arrives.

Use the recently added peerapi endpoint to send the addresses.

Updates tailscale/corp#34258
Signed-off-by: Fran Bull <fran@tailscale.com>
This commit is contained in:
Fran Bull
2026-02-27 14:31:43 -08:00
committed by franbull
parent 6a19995f13
commit a4614d7d17
10 changed files with 486 additions and 37 deletions
+40 -10
View File
@@ -7,6 +7,7 @@ import (
"cmp"
"slices"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/types/appctype"
"tailscale.com/util/mak"
@@ -15,6 +16,43 @@ import (
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
func isEligibleConnector(peer tailcfg.NodeView) bool {
if !peer.Valid() || !peer.Hostinfo().Valid() {
return false
}
isConn, _ := peer.Hostinfo().AppConnector().Get()
return isConn
}
func sortByPreference(ns []tailcfg.NodeView) {
// The ordering of the nodes is semantic (callers use the first node they can
// get a peer api url for). We don't (currently 2026-02-27) have any
// preference over which node is chosen as long as it's consistent. In the
// future we anticipate integrating with traffic steering.
slices.SortFunc(ns, func(a, b tailcfg.NodeView) int {
return cmp.Compare(a.ID(), b.ID())
})
}
// PickConnector returns peers the backend knows about that match the app, in order of preference to use as
// a connector.
func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.NodeView {
appTagsSet := set.SetOf(app.Connectors)
matches := nb.AppendMatchingPeers(nil, func(n tailcfg.NodeView) bool {
if !isEligibleConnector(n) {
return false
}
for _, t := range n.Tags().All() {
if appTagsSet.Contains(t) {
return true
}
}
return false
})
sortByPreference(matches)
return matches
}
// PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers
// want to be connectors for which domains.
func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView, peers map[tailcfg.NodeID]tailcfg.NodeView) map[string][]tailcfg.NodeView {
@@ -36,10 +74,7 @@ func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.
// use a Set of NodeIDs to deduplicate, and populate into a []NodeView later.
var work map[string]set.Set[tailcfg.NodeID]
for _, peer := range peers {
if !peer.Valid() || !peer.Hostinfo().Valid() {
continue
}
if isConn, _ := peer.Hostinfo().AppConnector().Get(); !isConn {
if !isEligibleConnector(peer) {
continue
}
for _, t := range peer.Tags().All() {
@@ -60,12 +95,7 @@ func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.
for id := range ids {
nodes = append(nodes, peers[id])
}
// The ordering of the nodes in the map vals is semantic (dnsConfigForNetmap uses the first node it can
// get a peer api url for as its split dns target). We can think of it as a preference order, except that
// we don't (currently 2026-01-14) have any preference over which node is chosen.
slices.SortFunc(nodes, func(a, b tailcfg.NodeView) int {
return cmp.Compare(a.ID(), b.ID())
})
sortByPreference(nodes)
mak.Set(&m, domain, nodes)
}
return m
+156
View File
@@ -8,6 +8,8 @@ import (
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/types/appctype"
"tailscale.com/types/opt"
@@ -131,3 +133,157 @@ func TestPickSplitDNSPeers(t *testing.T) {
})
}
}
type testNodeBackend struct {
ipnext.NodeBackend
peers []tailcfg.NodeView
}
func (nb *testNodeBackend) AppendMatchingPeers(base []tailcfg.NodeView, pred func(tailcfg.NodeView) bool) []tailcfg.NodeView {
for _, p := range nb.peers {
if pred(p) {
base = append(base, p)
}
}
return base
}
func (nb *testNodeBackend) PeerHasPeerAPI(p tailcfg.NodeView) bool {
return true
}
func TestPickConnector(t *testing.T) {
exampleApp := appctype.Conn25Attr{
Name: "example",
Connectors: []string{"tag:example"},
Domains: []string{"example.com"},
}
nvWithConnectorSet := func(id tailcfg.NodeID, isConnector bool, tags ...string) tailcfg.NodeView {
return (&tailcfg.Node{
ID: id,
Tags: tags,
Hostinfo: (&tailcfg.Hostinfo{AppConnector: opt.NewBool(isConnector)}).View(),
}).View()
}
nv := func(id tailcfg.NodeID, tags ...string) tailcfg.NodeView {
return nvWithConnectorSet(id, true, tags...)
}
for _, tt := range []struct {
name string
candidates []tailcfg.NodeView
app appctype.Conn25Attr
want []tailcfg.NodeView
}{
{
name: "empty-everything",
candidates: []tailcfg.NodeView{},
app: appctype.Conn25Attr{},
want: nil,
},
{
name: "empty-candidates",
candidates: []tailcfg.NodeView{},
app: exampleApp,
want: nil,
},
{
name: "empty-app",
candidates: []tailcfg.NodeView{nv(1, "tag:example")},
app: appctype.Conn25Attr{},
want: nil,
},
{
name: "one-matches",
candidates: []tailcfg.NodeView{nv(1, "tag:example")},
app: exampleApp,
want: []tailcfg.NodeView{nv(1, "tag:example")},
},
{
name: "invalid-candidate",
candidates: []tailcfg.NodeView{
{},
nv(1, "tag:example"),
},
app: exampleApp,
want: []tailcfg.NodeView{
nv(1, "tag:example"),
},
},
{
name: "no-host-info",
candidates: []tailcfg.NodeView{
(&tailcfg.Node{
ID: 1,
Tags: []string{"tag:example"},
}).View(),
nv(2, "tag:example"),
},
app: exampleApp,
want: []tailcfg.NodeView{nv(2, "tag:example")},
},
{
name: "not-a-connector",
candidates: []tailcfg.NodeView{nvWithConnectorSet(1, false, "tag:example.com"), nv(2, "tag:example")},
app: exampleApp,
want: []tailcfg.NodeView{nv(2, "tag:example")},
},
{
name: "without-matches",
candidates: []tailcfg.NodeView{nv(1, "tag:woo"), nv(2, "tag:example")},
app: exampleApp,
want: []tailcfg.NodeView{nv(2, "tag:example")},
},
{
name: "multi-tags",
candidates: []tailcfg.NodeView{nv(1, "tag:woo", "tag:hoo"), nv(2, "tag:woo", "tag:example")},
app: exampleApp,
want: []tailcfg.NodeView{nv(2, "tag:woo", "tag:example")},
},
{
name: "multi-matches",
candidates: []tailcfg.NodeView{nv(1, "tag:woo", "tag:hoo"), nv(2, "tag:woo", "tag:example"), nv(3, "tag:example1", "tag:example")},
app: appctype.Conn25Attr{
Name: "example2",
Connectors: []string{"tag:example1", "tag:example"},
Domains: []string{"example.com"},
},
want: []tailcfg.NodeView{nv(2, "tag:woo", "tag:example"), nv(3, "tag:example1", "tag:example")},
},
{
name: "bit-of-everything",
candidates: []tailcfg.NodeView{
nv(3, "tag:woo", "tag:hoo"),
{},
nv(2, "tag:woo", "tag:example"),
nvWithConnectorSet(4, false, "tag:example"),
nv(1, "tag:example1", "tag:example"),
nv(7, "tag:example1", "tag:example"),
nvWithConnectorSet(5, false),
nv(6),
nvWithConnectorSet(8, false, "tag:example"),
nvWithConnectorSet(9, false),
nvWithConnectorSet(10, false),
},
app: appctype.Conn25Attr{
Name: "example2",
Connectors: []string{"tag:example1", "tag:example", "tag:example2"},
Domains: []string{"example.com"},
},
want: []tailcfg.NodeView{
nv(1, "tag:example1", "tag:example"),
nv(2, "tag:woo", "tag:example"),
nv(7, "tag:example1", "tag:example"),
},
},
} {
t.Run(tt.name, func(t *testing.T) {
got := PickConnector(&testNodeBackend{peers: tt.candidates}, tt.app)
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Fatalf("PickConnectors (-want, +got):\n%s", diff)
}
})
}
}