appc,feature/conn25: prevent clients from forwarding DNS requests and
modifying DNS responses for domains they are also connectors for For Connectors 2025, determine if a client is configured as a connector and what domains it is a connector for. When acting as a client, don't install Split DNS routes to other connectors for those domains, and don't alter DNS responses for those domains. The responses are forwarded back to the original client, which in turn does the alteration, swapping the real IP for a Magic IP. A client is also a connector for a domain if it has tags that overlap with tags in the configured policy, and --advertise-connector=true in the prefs (not in the self-node Hostinfo from the netmap). We use the prefs as the source of truth because control only gets a copy from the prefs, and may drift. And the AppConnector field is currently zeroed out in the self-node Hostinfo from control. The extension adds a ProfileStateChange hook to process prefs changes, and the config type is split into prefs and nodeview sub-configs. Fixes tailscale/corp#39317 Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
This commit is contained in:
committed by
mzbenami
parent
4f47c3c93d
commit
1dc08f4d41
+18
-5
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
|
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
|
||||||
|
|
||||||
func isEligibleConnector(peer tailcfg.NodeView) bool {
|
func isPeerEligibleConnector(peer tailcfg.NodeView) bool {
|
||||||
if !peer.Valid() || !peer.Hostinfo().Valid() {
|
if !peer.Valid() || !peer.Hostinfo().Valid() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ func sortByPreference(ns []tailcfg.NodeView) {
|
|||||||
func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.NodeView {
|
func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.NodeView {
|
||||||
appTagsSet := set.SetOf(app.Connectors)
|
appTagsSet := set.SetOf(app.Connectors)
|
||||||
matches := nb.AppendMatchingPeers(nil, func(n tailcfg.NodeView) bool {
|
matches := nb.AppendMatchingPeers(nil, func(n tailcfg.NodeView) bool {
|
||||||
if !isEligibleConnector(n) {
|
if !isPeerEligibleConnector(n) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, t := range n.Tags().All() {
|
for _, t := range n.Tags().All() {
|
||||||
@@ -55,7 +55,7 @@ func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.Nod
|
|||||||
|
|
||||||
// PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers
|
// PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers
|
||||||
// want to be connectors for which domains.
|
// 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 {
|
func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView, peers map[tailcfg.NodeID]tailcfg.NodeView, isSelfEligibleConnector bool) map[string][]tailcfg.NodeView {
|
||||||
var m map[string][]tailcfg.NodeView
|
var m map[string][]tailcfg.NodeView
|
||||||
if !hasCap(AppConnectorsExperimentalAttrName) {
|
if !hasCap(AppConnectorsExperimentalAttrName) {
|
||||||
return m
|
return m
|
||||||
@@ -65,21 +65,34 @@ func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
tagToDomain := make(map[string][]string)
|
tagToDomain := make(map[string][]string)
|
||||||
|
selfTags := set.SetOf(self.Tags().AsSlice())
|
||||||
|
selfRoutedDomains := set.Set[string]{}
|
||||||
for _, app := range apps {
|
for _, app := range apps {
|
||||||
for _, tag := range app.Connectors {
|
for _, tag := range app.Connectors {
|
||||||
tagToDomain[tag] = append(tagToDomain[tag], app.Domains...)
|
domains := tagToDomain[tag]
|
||||||
|
domains = slices.Grow(domains, len(app.Domains))
|
||||||
|
for _, d := range app.Domains {
|
||||||
|
if isSelfEligibleConnector && selfTags.Contains(tag) {
|
||||||
|
selfRoutedDomains.Add(d)
|
||||||
|
}
|
||||||
|
domains = append(domains, d)
|
||||||
|
}
|
||||||
|
tagToDomain[tag] = domains
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NodeIDs are Comparable, and we have a map of NodeID to NodeView anyway, so
|
// NodeIDs are Comparable, and we have a map of NodeID to NodeView anyway, so
|
||||||
// use a Set of NodeIDs to deduplicate, and populate into a []NodeView later.
|
// use a Set of NodeIDs to deduplicate, and populate into a []NodeView later.
|
||||||
var work map[string]set.Set[tailcfg.NodeID]
|
var work map[string]set.Set[tailcfg.NodeID]
|
||||||
for _, peer := range peers {
|
for _, peer := range peers {
|
||||||
if !isEligibleConnector(peer) {
|
if !isPeerEligibleConnector(peer) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, t := range peer.Tags().All() {
|
for _, t := range peer.Tags().All() {
|
||||||
domains := tagToDomain[t]
|
domains := tagToDomain[t]
|
||||||
for _, domain := range domains {
|
for _, domain := range domains {
|
||||||
|
if selfRoutedDomains.Contains(domain) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if work[domain] == nil {
|
if work[domain] == nil {
|
||||||
mak.Set(&work, domain, set.Set[tailcfg.NodeID]{})
|
mak.Set(&work, domain, set.Set[tailcfg.NodeID]{})
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-5
@@ -47,10 +47,12 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
|||||||
nvp4 := makeNodeView(4, "p4", []string{"tag:two", "tag:three2", "tag:four2"})
|
nvp4 := makeNodeView(4, "p4", []string{"tag:two", "tag:three2", "tag:four2"})
|
||||||
|
|
||||||
for _, tt := range []struct {
|
for _, tt := range []struct {
|
||||||
name string
|
name string
|
||||||
want map[string][]tailcfg.NodeView
|
peers []tailcfg.NodeView
|
||||||
peers []tailcfg.NodeView
|
config []tailcfg.RawMessage
|
||||||
config []tailcfg.RawMessage
|
isEligibleConnector bool
|
||||||
|
selfTags []string
|
||||||
|
want map[string][]tailcfg.NodeView
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty",
|
name: "empty",
|
||||||
@@ -111,6 +113,85 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
|||||||
"c.example.com": {nvp2, nvp4},
|
"c.example.com": {nvp2, nvp4},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "self-connector-exclude-self-domains",
|
||||||
|
config: []tailcfg.RawMessage{
|
||||||
|
tailcfg.RawMessage(appOneBytes),
|
||||||
|
tailcfg.RawMessage(appTwoBytes),
|
||||||
|
tailcfg.RawMessage(appThreeBytes),
|
||||||
|
tailcfg.RawMessage(appFourBytes),
|
||||||
|
},
|
||||||
|
peers: []tailcfg.NodeView{
|
||||||
|
nvp1,
|
||||||
|
nvp2,
|
||||||
|
nvp3,
|
||||||
|
nvp4,
|
||||||
|
},
|
||||||
|
isEligibleConnector: true,
|
||||||
|
selfTags: []string{"tag:three1"},
|
||||||
|
want: map[string][]tailcfg.NodeView{
|
||||||
|
// woo.b.example.com and hoo.b.example.com are covered
|
||||||
|
// by tag:three1, and so is this self-node.
|
||||||
|
// So those domains should not be routed to peers.
|
||||||
|
// woo.b.example.com is also covered by another tag,
|
||||||
|
// but still not included since this connector can route to it.
|
||||||
|
"example.com": {nvp1},
|
||||||
|
"a.example.com": {nvp3, nvp4},
|
||||||
|
"c.example.com": {nvp2, nvp4},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "self-eligible-connector-no-matching-tag-include-all-domains",
|
||||||
|
config: []tailcfg.RawMessage{
|
||||||
|
tailcfg.RawMessage(appOneBytes),
|
||||||
|
tailcfg.RawMessage(appTwoBytes),
|
||||||
|
tailcfg.RawMessage(appThreeBytes),
|
||||||
|
tailcfg.RawMessage(appFourBytes),
|
||||||
|
},
|
||||||
|
peers: []tailcfg.NodeView{
|
||||||
|
nvp1,
|
||||||
|
nvp2,
|
||||||
|
nvp3,
|
||||||
|
nvp4,
|
||||||
|
},
|
||||||
|
isEligibleConnector: true,
|
||||||
|
selfTags: []string{"tag:unrelated"},
|
||||||
|
want: map[string][]tailcfg.NodeView{
|
||||||
|
// Self has prefs set but no tags matching any app,
|
||||||
|
// so no domains are self-routed and all appear.
|
||||||
|
"example.com": {nvp1},
|
||||||
|
"a.example.com": {nvp3, nvp4},
|
||||||
|
"woo.b.example.com": {nvp2, nvp3, nvp4},
|
||||||
|
"hoo.b.example.com": {nvp3, nvp4},
|
||||||
|
"c.example.com": {nvp2, nvp4},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "self-not-eligible-connector-but-tagged-include-all-domains",
|
||||||
|
config: []tailcfg.RawMessage{
|
||||||
|
tailcfg.RawMessage(appOneBytes),
|
||||||
|
tailcfg.RawMessage(appTwoBytes),
|
||||||
|
tailcfg.RawMessage(appThreeBytes),
|
||||||
|
tailcfg.RawMessage(appFourBytes),
|
||||||
|
},
|
||||||
|
peers: []tailcfg.NodeView{
|
||||||
|
nvp1,
|
||||||
|
nvp2,
|
||||||
|
nvp3,
|
||||||
|
nvp4,
|
||||||
|
},
|
||||||
|
selfTags: []string{"tag:three1"},
|
||||||
|
want: map[string][]tailcfg.NodeView{
|
||||||
|
// Even though this self node has a tag for an app
|
||||||
|
// the prefs don't advertise as connector, so
|
||||||
|
// should still route through other connectors.
|
||||||
|
"example.com": {nvp1},
|
||||||
|
"a.example.com": {nvp3, nvp4},
|
||||||
|
"woo.b.example.com": {nvp2, nvp3, nvp4},
|
||||||
|
"hoo.b.example.com": {nvp3, nvp4},
|
||||||
|
"c.example.com": {nvp2, nvp4},
|
||||||
|
},
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
selfNode := &tailcfg.Node{}
|
selfNode := &tailcfg.Node{}
|
||||||
@@ -119,6 +200,7 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
|||||||
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): tt.config,
|
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): tt.config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
selfNode.Tags = append(selfNode.Tags, tt.selfTags...)
|
||||||
selfView := selfNode.View()
|
selfView := selfNode.View()
|
||||||
peers := map[tailcfg.NodeID]tailcfg.NodeView{}
|
peers := map[tailcfg.NodeID]tailcfg.NodeView{}
|
||||||
for _, p := range tt.peers {
|
for _, p := range tt.peers {
|
||||||
@@ -126,7 +208,8 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool {
|
got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool {
|
||||||
return true
|
return true
|
||||||
}, selfView, peers)
|
}, selfView, peers, tt.isEligibleConnector)
|
||||||
|
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
t.Fatalf("got %v, want %v", got, tt.want)
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-59
@@ -25,6 +25,7 @@ import (
|
|||||||
"tailscale.com/appc"
|
"tailscale.com/appc"
|
||||||
"tailscale.com/envknob"
|
"tailscale.com/envknob"
|
||||||
"tailscale.com/feature"
|
"tailscale.com/feature"
|
||||||
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnext"
|
"tailscale.com/ipn/ipnext"
|
||||||
"tailscale.com/ipn/ipnlocal"
|
"tailscale.com/ipn/ipnlocal"
|
||||||
"tailscale.com/net/packet"
|
"tailscale.com/net/packet"
|
||||||
@@ -124,6 +125,7 @@ func (e *extension) Init(host ipnext.Host) error {
|
|||||||
if err := e.installHooks(dph); err != nil {
|
if err := e.installHooks(dph); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
e.seedPrefsConfig()
|
||||||
|
|
||||||
ctx, cancel := context.WithCancelCause(context.Background())
|
ctx, cancel := context.WithCancelCause(context.Background())
|
||||||
e.ctxCancel = cancel
|
e.ctxCancel = cancel
|
||||||
@@ -167,10 +169,13 @@ func (e *extension) installHooks(dph *datapathHandler) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Manage how we react to changes to the current node,
|
// Manage how we react to changes to the current node,
|
||||||
// including property changes (e.g. HostInfo, Capabilities, CapMap)
|
// including property changes (e.g. HostInfo, Capabilities, CapMap).
|
||||||
// and profile switches.
|
|
||||||
e.host.Hooks().OnSelfChange.Add(e.onSelfChange)
|
e.host.Hooks().OnSelfChange.Add(e.onSelfChange)
|
||||||
|
|
||||||
|
// Manage how we react profile state changes, which include
|
||||||
|
// prefs changes.
|
||||||
|
e.host.Hooks().ProfileStateChange.Add(e.profileStateChange)
|
||||||
|
|
||||||
// Allow the client to send packets with Transit IP destinations
|
// Allow the client to send packets with Transit IP destinations
|
||||||
// in the link-local space.
|
// in the link-local space.
|
||||||
e.host.Hooks().Filter.LinkLocalAllowHooks.Add(func(p packet.Parsed) (bool, string) {
|
e.host.Hooks().Filter.LinkLocalAllowHooks.Add(func(p packet.Parsed) (bool, string) {
|
||||||
@@ -217,6 +222,15 @@ func (e *extension) installHooks(dph *datapathHandler) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedPrefsConfig provides an initial prefs config before
|
||||||
|
// any hooks fire. The OnSelfChange hook needs to fire
|
||||||
|
// for Conn25 to be fully configured and ready to use.
|
||||||
|
func (e *extension) seedPrefsConfig() {
|
||||||
|
var cfg config
|
||||||
|
cfg.prefs = configFromPrefs(e.host.Profiles().CurrentPrefs())
|
||||||
|
e.conn25.reconfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
// ClientTransitIPForMagicIP implements [IPMapper].
|
// ClientTransitIPForMagicIP implements [IPMapper].
|
||||||
func (c *Conn25) ClientTransitIPForMagicIP(m netip.Addr) (netip.Addr, error) {
|
func (c *Conn25) ClientTransitIPForMagicIP(m netip.Addr) (netip.Addr, error) {
|
||||||
return c.client.transitIPForMagicIP(m)
|
return c.client.transitIPForMagicIP(m)
|
||||||
@@ -229,7 +243,7 @@ func (c *Conn25) ConnectorRealIPForTransitIPConnection(src, transit netip.Addr)
|
|||||||
|
|
||||||
func (e *extension) getMagicRange() views.Slice[netip.Prefix] {
|
func (e *extension) getMagicRange() views.Slice[netip.Prefix] {
|
||||||
cfg := e.conn25.client.getConfig()
|
cfg := e.conn25.client.getConfig()
|
||||||
return views.SliceOf(slices.Concat(cfg.v4MagicIPSet.Prefixes(), cfg.v6MagicIPSet.Prefixes()))
|
return views.SliceOf(slices.Concat(cfg.nv.v4MagicIPSet.Prefixes(), cfg.nv.v6MagicIPSet.Prefixes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown implements [ipnlocal.Extension].
|
// Shutdown implements [ipnlocal.Extension].
|
||||||
@@ -264,12 +278,30 @@ func (e *extension) handleConnectorTransitIP(h ipnlocal.PeerAPIHandler, w http.R
|
|||||||
w.Write(bs)
|
w.Write(bs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onSelfChange implements the [ipnext.Hooks.OnSelfChange] hook.
|
||||||
|
// It reads then modifies the current config. We expect that OnSelfChange
|
||||||
|
// is not called concurrently with itself or with ProfileStateChange to
|
||||||
|
// prevent TOCTOU errors.
|
||||||
func (e *extension) onSelfChange(selfNode tailcfg.NodeView) {
|
func (e *extension) onSelfChange(selfNode tailcfg.NodeView) {
|
||||||
err := e.conn25.reconfig(selfNode)
|
cfg := e.conn25.client.getConfig()
|
||||||
|
nvCfg, err := configFromNodeView(selfNode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.conn25.client.logf("error during Reconfig onSelfChange: %v", err)
|
e.conn25.client.logf("error generating config from self node view: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cfg.nv = nvCfg
|
||||||
|
e.conn25.reconfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileStateChange implements the [ipnext.Hooks.ProfileStateChange] hook.
|
||||||
|
// It reads then modifies the current config. We expect that ProfileStateChange
|
||||||
|
// is not called concurrently with itself or with OnSelfChange to
|
||||||
|
// prevent TOCTOU errors.
|
||||||
|
func (e *extension) profileStateChange(loginProfile ipn.LoginProfileView, prefs ipn.PrefsView, sameNode bool) {
|
||||||
|
// TODO(mzb): Handle node changes. Wipe out all config?
|
||||||
|
cfg := e.conn25.client.getConfig()
|
||||||
|
cfg.prefs = configFromPrefs(prefs)
|
||||||
|
e.conn25.reconfig(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *extension) extraWireGuardAllowedIPs(k key.NodePublic) views.Slice[netip.Prefix] {
|
func (e *extension) extraWireGuardAllowedIPs(k key.NodePublic) views.Slice[netip.Prefix] {
|
||||||
@@ -283,6 +315,7 @@ type appAddr struct {
|
|||||||
|
|
||||||
// Conn25 holds state for routing traffic for a domain via a connector.
|
// Conn25 holds state for routing traffic for a domain via a connector.
|
||||||
type Conn25 struct {
|
type Conn25 struct {
|
||||||
|
mu sync.Mutex // mu protects reconfiguration of client and connector
|
||||||
client *client
|
client *client
|
||||||
connector *connector
|
connector *connector
|
||||||
}
|
}
|
||||||
@@ -310,18 +343,12 @@ func ipSetFromIPRanges(rs []netipx.IPRange) (*netipx.IPSet, error) {
|
|||||||
return b.IPSet()
|
return b.IPSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Conn25) reconfig(selfNode tailcfg.NodeView) error {
|
func (c *Conn25) reconfig(cfg config) {
|
||||||
cfg, err := configFromNodeView(selfNode)
|
c.mu.Lock()
|
||||||
if err != nil {
|
defer c.mu.Unlock()
|
||||||
return err
|
|
||||||
}
|
c.client.reconfig(cfg)
|
||||||
if err := c.client.reconfig(cfg); err != nil {
|
c.connector.reconfig(cfg)
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := c.connector.reconfig(cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapDNSResponse parses and inspects the DNS response, and uses the
|
// mapDNSResponse parses and inspects the DNS response, and uses the
|
||||||
@@ -397,7 +424,7 @@ func (c *connector) handleTransitIPRequest(n tailcfg.NodeView, peerV4 netip.Addr
|
|||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if _, ok := c.config.appsByName[tipr.App]; !ok {
|
if _, ok := c.config.nv.appsByName[tipr.App]; !ok {
|
||||||
c.logf("[Unexpected] peer attempt to map a transit IP referenced unknown app: node: %s, app: %q",
|
c.logf("[Unexpected] peer attempt to map a transit IP referenced unknown app: node: %s, app: %q",
|
||||||
n.StableID(), tipr.App)
|
n.StableID(), tipr.App)
|
||||||
return TransitIPResponse{Code: UnknownAppName, Message: unknownAppNameMessage}
|
return TransitIPResponse{Code: UnknownAppName, Message: unknownAppNameMessage}
|
||||||
@@ -485,69 +512,71 @@ type ConnectorTransitIPResponse struct {
|
|||||||
|
|
||||||
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
|
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
|
||||||
|
|
||||||
// config holds the config from the policy and lookups derived from that.
|
// nodeViewConfig holds the config derived from the self node view,
|
||||||
// config is not safe for concurrent use.
|
// which includes the policy.
|
||||||
type config struct {
|
// nodeViewConfig is not safe for concurrent use.
|
||||||
isConfigured bool
|
type nodeViewConfig struct {
|
||||||
apps []appctype.Conn25Attr
|
isConfigured bool
|
||||||
appsByName map[string]appctype.Conn25Attr
|
apps []appctype.Conn25Attr
|
||||||
appNamesByDomain map[dnsname.FQDN][]string
|
appsByName map[string]appctype.Conn25Attr
|
||||||
selfRoutedDomains set.Set[dnsname.FQDN]
|
appNamesByDomain map[dnsname.FQDN][]string
|
||||||
v4TransitIPSet netipx.IPSet
|
selfDomains set.Set[dnsname.FQDN]
|
||||||
v4MagicIPSet netipx.IPSet
|
v4TransitIPSet netipx.IPSet
|
||||||
v6TransitIPSet netipx.IPSet
|
v4MagicIPSet netipx.IPSet
|
||||||
v6MagicIPSet netipx.IPSet
|
v6TransitIPSet netipx.IPSet
|
||||||
|
v6MagicIPSet netipx.IPSet
|
||||||
}
|
}
|
||||||
|
|
||||||
func configFromNodeView(n tailcfg.NodeView) (config, error) {
|
func configFromNodeView(n tailcfg.NodeView) (nodeViewConfig, error) {
|
||||||
apps, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.Conn25Attr](n.CapMap(), AppConnectorsExperimentalAttrName)
|
apps, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.Conn25Attr](n.CapMap(), AppConnectorsExperimentalAttrName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config{}, err
|
return nodeViewConfig{}, err
|
||||||
}
|
}
|
||||||
if len(apps) == 0 {
|
if len(apps) == 0 {
|
||||||
return config{}, nil
|
return nodeViewConfig{}, nil
|
||||||
}
|
}
|
||||||
selfTags := set.SetOf(n.Tags().AsSlice())
|
selfTags := set.SetOf(n.Tags().AsSlice())
|
||||||
cfg := config{
|
cfg := nodeViewConfig{
|
||||||
isConfigured: true,
|
isConfigured: true,
|
||||||
apps: apps,
|
apps: apps,
|
||||||
appsByName: map[string]appctype.Conn25Attr{},
|
appsByName: map[string]appctype.Conn25Attr{},
|
||||||
appNamesByDomain: map[dnsname.FQDN][]string{},
|
appNamesByDomain: map[dnsname.FQDN][]string{},
|
||||||
selfRoutedDomains: set.Set[dnsname.FQDN]{},
|
selfDomains: set.Set[dnsname.FQDN]{},
|
||||||
}
|
}
|
||||||
for _, app := range apps {
|
for _, app := range apps {
|
||||||
selfMatchesTags := slices.ContainsFunc(app.Connectors, selfTags.Contains)
|
selfMatchesTags := slices.ContainsFunc(app.Connectors, selfTags.Contains)
|
||||||
for _, d := range app.Domains {
|
for _, d := range app.Domains {
|
||||||
fqdn, err := normalizeDNSName(d)
|
fqdn, err := normalizeDNSName(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config{}, err
|
return nodeViewConfig{}, err
|
||||||
}
|
}
|
||||||
mak.Set(&cfg.appNamesByDomain, fqdn, append(cfg.appNamesByDomain[fqdn], app.Name))
|
mak.Set(&cfg.appNamesByDomain, fqdn, append(cfg.appNamesByDomain[fqdn], app.Name))
|
||||||
if selfMatchesTags {
|
if selfMatchesTags {
|
||||||
cfg.selfRoutedDomains.Add(fqdn)
|
cfg.selfDomains.Add(fqdn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mak.Set(&cfg.appsByName, app.Name, app)
|
mak.Set(&cfg.appsByName, app.Name, app)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(fran) 2026-03-18 we don't yet have a proper way to communicate the
|
// TODO(fran) 2026-03-18 we don't yet have a proper way to communicate the
|
||||||
// global IP pool config. For now just take it from the first app.
|
// global IP pool config. For now just take it from the first app.
|
||||||
if len(apps) != 0 {
|
if len(apps) != 0 {
|
||||||
app := apps[0]
|
app := apps[0]
|
||||||
v4Mipp, err := ipSetFromIPRanges(app.V4MagicIPPool)
|
v4Mipp, err := ipSetFromIPRanges(app.V4MagicIPPool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config{}, err
|
return nodeViewConfig{}, err
|
||||||
}
|
}
|
||||||
v4Tipp, err := ipSetFromIPRanges(app.V4TransitIPPool)
|
v4Tipp, err := ipSetFromIPRanges(app.V4TransitIPPool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config{}, err
|
return nodeViewConfig{}, err
|
||||||
}
|
}
|
||||||
v6Mipp, err := ipSetFromIPRanges(app.V6MagicIPPool)
|
v6Mipp, err := ipSetFromIPRanges(app.V6MagicIPPool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config{}, err
|
return nodeViewConfig{}, err
|
||||||
}
|
}
|
||||||
v6Tipp, err := ipSetFromIPRanges(app.V6TransitIPPool)
|
v6Tipp, err := ipSetFromIPRanges(app.V6TransitIPPool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config{}, err
|
return nodeViewConfig{}, err
|
||||||
}
|
}
|
||||||
cfg.v4MagicIPSet = *v4Mipp
|
cfg.v4MagicIPSet = *v4Mipp
|
||||||
cfg.v4TransitIPSet = *v4Tipp
|
cfg.v4TransitIPSet = *v4Tipp
|
||||||
@@ -557,6 +586,33 @@ func configFromNodeView(n tailcfg.NodeView) (config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prefsConfig holds the config derived from the current prefs.
|
||||||
|
// prefsConfig is not safe for concurrent use.
|
||||||
|
type prefsConfig struct {
|
||||||
|
isConfigured bool
|
||||||
|
isEligibleConnector bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func configFromPrefs(prefs ipn.PrefsView) prefsConfig {
|
||||||
|
return prefsConfig{
|
||||||
|
isConfigured: true,
|
||||||
|
isEligibleConnector: prefs.AppConnector().Advertise,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// config wraps the config from disparate sources.
|
||||||
|
// config is not safe for concurrent use.
|
||||||
|
type config struct {
|
||||||
|
nv nodeViewConfig
|
||||||
|
prefs prefsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSelfRoutedDomain reports whether the self node is currently
|
||||||
|
// acting as a connector for the given domain.
|
||||||
|
func (c config) isSelfRoutedDomain(d dnsname.FQDN) bool {
|
||||||
|
return c.prefs.isEligibleConnector && c.nv.selfDomains.Contains(d)
|
||||||
|
}
|
||||||
|
|
||||||
// client performs the conn25 functionality for clients of connectors
|
// client performs the conn25 functionality for clients of connectors
|
||||||
// It allocates magic and transit IP addresses and communicates them with
|
// It allocates magic and transit IP addresses and communicates them with
|
||||||
// connectors.
|
// connectors.
|
||||||
@@ -589,7 +645,7 @@ func (c *client) transitIPForMagicIP(magicIP netip.Addr) (netip.Addr, error) {
|
|||||||
if ok {
|
if ok {
|
||||||
return v.transit, nil
|
return v.transit, nil
|
||||||
}
|
}
|
||||||
if !c.config.v4MagicIPSet.Contains(magicIP) && !c.config.v6MagicIPSet.Contains(magicIP) {
|
if !c.config.nv.v4MagicIPSet.Contains(magicIP) && !c.config.nv.v6MagicIPSet.Contains(magicIP) {
|
||||||
return netip.Addr{}, nil
|
return netip.Addr{}, nil
|
||||||
}
|
}
|
||||||
return netip.Addr{}, ErrUnmappedMagicIP
|
return netip.Addr{}, ErrUnmappedMagicIP
|
||||||
@@ -615,29 +671,40 @@ func (c *client) isKnownTransitIP(tip netip.Addr) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isConfigured reports whether the client has received both a node view
|
||||||
|
// config (from the netmap/policy) and a prefs config. Both are required
|
||||||
|
// before the feature is active.
|
||||||
func (c *client) isConfigured() bool {
|
func (c *client) isConfigured() bool {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
return c.config.isConfigured
|
return c.config.prefs.isConfigured && c.config.nv.isConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *client) reconfig(newCfg config) error {
|
func (c *client) reconfig(newCfg config) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
c.config = newCfg
|
c.config = newCfg
|
||||||
|
|
||||||
c.v4MagicIPPool = newIPPool(&(newCfg.v4MagicIPSet))
|
c.v4MagicIPPool = newIPPool(&(newCfg.nv.v4MagicIPSet))
|
||||||
c.v4TransitIPPool = newIPPool(&(newCfg.v4TransitIPSet))
|
c.v4TransitIPPool = newIPPool(&(newCfg.nv.v4TransitIPSet))
|
||||||
c.v6MagicIPPool = newIPPool(&(newCfg.v6MagicIPSet))
|
c.v6MagicIPPool = newIPPool(&(newCfg.nv.v6MagicIPSet))
|
||||||
c.v6TransitIPPool = newIPPool(&(newCfg.v6TransitIPSet))
|
c.v6TransitIPPool = newIPPool(&(newCfg.nv.v6TransitIPSet))
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isConnectorDomain returns true if the domain is expected
|
||||||
|
// to be routed through a peer connector, but returns false
|
||||||
|
// if the self node is a connector responsible for routing the
|
||||||
|
// domain, and false in all other cases.
|
||||||
func (c *client) isConnectorDomain(domain dnsname.FQDN) bool {
|
func (c *client) isConnectorDomain(domain dnsname.FQDN) bool {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
appNames, ok := c.config.appNamesByDomain[domain]
|
|
||||||
|
if c.config.isSelfRoutedDomain(domain) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
appNames, ok := c.config.nv.appNamesByDomain[domain]
|
||||||
return ok && len(appNames) > 0
|
return ok && len(appNames) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,7 +721,7 @@ func (c *client) reserveAddresses(domain dnsname.FQDN, dst netip.Addr) (addrs, e
|
|||||||
if existing, ok := c.assignments.lookupByDomainDst(domain, dst); ok {
|
if existing, ok := c.assignments.lookupByDomainDst(domain, dst); ok {
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
appNames, _ := c.config.appNamesByDomain[domain]
|
appNames, _ := c.config.nv.appNamesByDomain[domain]
|
||||||
if len(appNames) == 0 {
|
if len(appNames) == 0 {
|
||||||
return addrs{}, fmt.Errorf("no app names found for domain %q", domain)
|
return addrs{}, fmt.Errorf("no app names found for domain %q", domain)
|
||||||
}
|
}
|
||||||
@@ -802,7 +869,7 @@ func makePeerAPIReq(ctx context.Context, httpClient *http.Client, urlBase string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *extension) sendAddressAssignment(ctx context.Context, as addrs) (tailcfg.NodeView, error) {
|
func (e *extension) sendAddressAssignment(ctx context.Context, as addrs) (tailcfg.NodeView, error) {
|
||||||
app, ok := e.conn25.client.getConfig().appsByName[as.app]
|
app, ok := e.conn25.client.getConfig().nv.appsByName[as.app]
|
||||||
if !ok {
|
if !ok {
|
||||||
e.conn25.client.logf("App not found for app: %s (domain: %s)", as.app, as.domain)
|
e.conn25.client.logf("App not found for app: %s (domain: %s)", as.app, as.domain)
|
||||||
return tailcfg.NodeView{}, errors.New("app not found")
|
return tailcfg.NodeView{}, errors.New("app not found")
|
||||||
@@ -1056,7 +1123,7 @@ func (c *connector) realIPForTransitIPConnection(srcIP netip.Addr, transitIP net
|
|||||||
if ok {
|
if ok {
|
||||||
return v.addr, nil
|
return v.addr, nil
|
||||||
}
|
}
|
||||||
if !c.config.v4TransitIPSet.Contains(transitIP) && !c.config.v6TransitIPSet.Contains(transitIP) {
|
if !c.config.nv.v4TransitIPSet.Contains(transitIP) && !c.config.nv.v6TransitIPSet.Contains(transitIP) {
|
||||||
return netip.Addr{}, nil
|
return netip.Addr{}, nil
|
||||||
}
|
}
|
||||||
return netip.Addr{}, ErrUnmappedSrcAndTransitIP
|
return netip.Addr{}, ErrUnmappedSrcAndTransitIP
|
||||||
@@ -1085,11 +1152,10 @@ func (c *connector) lookupBySrcIPAndTransitIP(srcIP, transitIP netip.Addr) (appA
|
|||||||
return v, ok
|
return v, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *connector) reconfig(newCfg config) error {
|
func (c *connector) reconfig(newCfg config) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.config = newCfg
|
c.config = newCfg
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type addrs struct {
|
type addrs struct {
|
||||||
|
|||||||
+205
-51
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"go4.org/mem"
|
"go4.org/mem"
|
||||||
"go4.org/netipx"
|
"go4.org/netipx"
|
||||||
"golang.org/x/net/dns/dnsmessage"
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnext"
|
"tailscale.com/ipn/ipnext"
|
||||||
"tailscale.com/net/dns"
|
"tailscale.com/net/dns"
|
||||||
"tailscale.com/net/packet"
|
"tailscale.com/net/packet"
|
||||||
@@ -339,7 +341,9 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
|
|||||||
// Use the same Conn25 for each request in the test and seed it with a test app name.
|
// Use the same Conn25 for each request in the test and seed it with a test app name.
|
||||||
c := newConn25(logger.Discard)
|
c := newConn25(logger.Discard)
|
||||||
c.connector.config = config{
|
c.connector.config = config{
|
||||||
appsByName: map[string]appctype.Conn25Attr{appName: {}},
|
nv: nodeViewConfig{
|
||||||
|
appsByName: map[string]appctype.Conn25Attr{appName: {}},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, peer := range tt.ctipReqPeers {
|
for i, peer := range tt.ctipReqPeers {
|
||||||
@@ -398,7 +402,7 @@ func TestReserveIPs(t *testing.T) {
|
|||||||
domainStr := "example.com."
|
domainStr := "example.com."
|
||||||
mbd := map[dnsname.FQDN][]string{}
|
mbd := map[dnsname.FQDN][]string{}
|
||||||
mbd["example.com."] = []string{app}
|
mbd["example.com."] = []string{app}
|
||||||
c.client.config.appNamesByDomain = mbd
|
c.client.config.nv.appNamesByDomain = mbd
|
||||||
domain := must.Get(dnsname.ToFQDN(domainStr))
|
domain := must.Get(dnsname.ToFQDN(domainStr))
|
||||||
|
|
||||||
for _, tt := range []struct {
|
for _, tt := range []struct {
|
||||||
@@ -453,29 +457,87 @@ func TestReconfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c := newConn25(logger.Discard)
|
c := newConn25(logger.Discard)
|
||||||
|
if c.isConfigured() {
|
||||||
|
t.Fatal("expected Conn25 to report unconfigured before reconfig")
|
||||||
|
}
|
||||||
|
|
||||||
sn := (&tailcfg.Node{
|
sn := (&tailcfg.Node{
|
||||||
CapMap: capMap,
|
CapMap: capMap,
|
||||||
}).View()
|
}).View()
|
||||||
|
cfg := mustConfig(t, sn, testPrefsIsConnector)
|
||||||
|
c.reconfig(cfg)
|
||||||
|
|
||||||
err := c.reconfig(sn)
|
if !c.isConfigured() {
|
||||||
if err != nil {
|
t.Fatal("expected Conn25 to report configured after reconfig")
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(c.client.config.apps) != 1 || c.client.config.apps[0].Name != "app1" {
|
if !reflect.DeepEqual(c.client.config, c.connector.config) {
|
||||||
t.Fatalf("want apps to have one entry 'app1', got %v", c.client.config.apps)
|
t.Fatal("client and connector have different configs")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.client.config.nv.apps) != 1 || c.client.config.nv.apps[0].Name != "app1" {
|
||||||
|
t.Fatalf("want apps to have one entry 'app1', got %v", c.client.config.nv.apps)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.client.config.prefs.isEligibleConnector != true {
|
||||||
|
t.Fatal("want prefs config to have isEligibleConnector=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.reconfig(config{
|
||||||
|
prefs: prefsConfig{isConfigured: true},
|
||||||
|
})
|
||||||
|
if c.isConfigured() {
|
||||||
|
t.Fatal("expected Conn25 to report unconfigured with only prefs config")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.reconfig(config{
|
||||||
|
nv: nodeViewConfig{isConfigured: true},
|
||||||
|
})
|
||||||
|
if c.isConfigured() {
|
||||||
|
t.Fatal("expected Conn25 to report unconfigured with only nodeview config")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigReconfig(t *testing.T) {
|
func TestConfigFromPrefs(t *testing.T) {
|
||||||
for _, tt := range []struct {
|
for _, tt := range []struct {
|
||||||
name string
|
name string
|
||||||
rawCfg string
|
prefs ipn.PrefsView
|
||||||
cfg []appctype.Conn25Attr
|
expected prefsConfig
|
||||||
tags []string
|
}{
|
||||||
wantErr bool
|
{
|
||||||
wantAppsByDomain map[dnsname.FQDN][]string
|
name: "is-eligible-connector",
|
||||||
wantSelfRoutedDomains set.Set[dnsname.FQDN]
|
prefs: testPrefsIsConnector,
|
||||||
|
expected: prefsConfig{
|
||||||
|
isConfigured: true,
|
||||||
|
isEligibleConnector: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not-eligible-connector",
|
||||||
|
prefs: testPrefsNotConnector,
|
||||||
|
expected: prefsConfig{
|
||||||
|
isConfigured: true,
|
||||||
|
isEligibleConnector: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if diff := cmp.Diff(tt.expected, configFromPrefs(tt.prefs), cmp.AllowUnexported(prefsConfig{})); diff != "" {
|
||||||
|
t.Errorf("unexpected prefsConfig (-want,+got): %s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigFromNodeView(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
rawCfg string
|
||||||
|
cfg []appctype.Conn25Attr
|
||||||
|
tags []string
|
||||||
|
wantErr bool
|
||||||
|
wantAppsByDomain map[dnsname.FQDN][]string
|
||||||
|
wantSelfDomains set.Set[dnsname.FQDN]
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "bad-config",
|
name: "bad-config",
|
||||||
@@ -493,10 +555,10 @@ func TestConfigReconfig(t *testing.T) {
|
|||||||
"a.example.com.": {"one"},
|
"a.example.com.": {"one"},
|
||||||
"b.example.com.": {"two"},
|
"b.example.com.": {"two"},
|
||||||
},
|
},
|
||||||
wantSelfRoutedDomains: set.SetOf([]dnsname.FQDN{"a.example.com."}),
|
wantSelfDomains: set.SetOf([]dnsname.FQDN{"a.example.com."}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "more-complex",
|
name: "more-complex-with-connector-self-domains",
|
||||||
cfg: []appctype.Conn25Attr{
|
cfg: []appctype.Conn25Attr{
|
||||||
{Name: "one", Domains: []string{"1.a.example.com", "1.b.example.com"}, Connectors: []string{"tag:one", "tag:onea"}},
|
{Name: "one", Domains: []string{"1.a.example.com", "1.b.example.com"}, Connectors: []string{"tag:one", "tag:onea"}},
|
||||||
{Name: "two", Domains: []string{"2.b.example.com", "2.c.example.com"}, Connectors: []string{"tag:two", "tag:twoa"}},
|
{Name: "two", Domains: []string{"2.b.example.com", "2.c.example.com"}, Connectors: []string{"tag:two", "tag:twoa"}},
|
||||||
@@ -513,7 +575,19 @@ func TestConfigReconfig(t *testing.T) {
|
|||||||
"4.b.example.com.": {"four"},
|
"4.b.example.com.": {"four"},
|
||||||
"4.d.example.com.": {"four"},
|
"4.d.example.com.": {"four"},
|
||||||
},
|
},
|
||||||
wantSelfRoutedDomains: set.SetOf([]dnsname.FQDN{"1.a.example.com.", "1.b.example.com.", "4.b.example.com.", "4.d.example.com."}),
|
wantSelfDomains: set.SetOf([]dnsname.FQDN{"1.a.example.com.", "1.b.example.com.", "4.b.example.com.", "4.d.example.com."}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "eligible-connector-no-matching-tag-no-self-domains",
|
||||||
|
cfg: []appctype.Conn25Attr{
|
||||||
|
{Name: "one", Domains: []string{"a.example.com"}, Connectors: []string{"tag:one"}},
|
||||||
|
{Name: "two", Domains: []string{"b.example.com"}, Connectors: []string{"tag:two"}},
|
||||||
|
},
|
||||||
|
tags: []string{"tag:unrelated"},
|
||||||
|
wantAppsByDomain: map[dnsname.FQDN][]string{
|
||||||
|
"a.example.com.": {"one"},
|
||||||
|
"b.example.com.": {"two"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -535,6 +609,7 @@ func TestConfigReconfig(t *testing.T) {
|
|||||||
CapMap: capMap,
|
CapMap: capMap,
|
||||||
Tags: tt.tags,
|
Tags: tt.tags,
|
||||||
}).View()
|
}).View()
|
||||||
|
|
||||||
c, err := configFromNodeView(sn)
|
c, err := configFromNodeView(sn)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Fatalf("wantErr: %t, err: %v", tt.wantErr, err)
|
t.Fatalf("wantErr: %t, err: %v", tt.wantErr, err)
|
||||||
@@ -542,8 +617,8 @@ func TestConfigReconfig(t *testing.T) {
|
|||||||
if diff := cmp.Diff(tt.wantAppsByDomain, c.appNamesByDomain); diff != "" {
|
if diff := cmp.Diff(tt.wantAppsByDomain, c.appNamesByDomain); diff != "" {
|
||||||
t.Errorf("appsByDomain diff (-want, +got):\n%s", diff)
|
t.Errorf("appsByDomain diff (-want, +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
if diff := cmp.Diff(tt.wantSelfRoutedDomains, c.selfRoutedDomains); diff != "" {
|
if diff := cmp.Diff(tt.wantSelfDomains, c.selfDomains); diff != "" {
|
||||||
t.Errorf("selfRoutedDomains diff (-want, +got):\n%s", diff)
|
t.Errorf("selfDomains diff (-want, +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -562,12 +637,33 @@ func makeSelfNode(t *testing.T, attrs []appctype.Conn25Attr, tags []string) tail
|
|||||||
capMap := tailcfg.NodeCapMap{
|
capMap := tailcfg.NodeCapMap{
|
||||||
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): cfg,
|
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): cfg,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (&tailcfg.Node{
|
return (&tailcfg.Node{
|
||||||
CapMap: capMap,
|
CapMap: capMap,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
}).View()
|
}).View()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
testPrefsIsConnector = (&ipn.Prefs{AppConnector: ipn.AppConnectorPrefs{Advertise: true}}).View()
|
||||||
|
testPrefsNotConnector = (&ipn.Prefs{AppConnector: ipn.AppConnectorPrefs{Advertise: false}}).View()
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustConfig(t *testing.T, selfNode tailcfg.NodeView, prefs ipn.PrefsView) config {
|
||||||
|
t.Helper()
|
||||||
|
nvCfg, err := configFromNodeView(selfNode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefsCfg := configFromPrefs(prefs)
|
||||||
|
|
||||||
|
return config{
|
||||||
|
nv: nvCfg,
|
||||||
|
prefs: prefsCfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func v4RangeFrom(from, to string) netipx.IPRange {
|
func v4RangeFrom(from, to string) netipx.IPRange {
|
||||||
return netipx.IPRangeFrom(
|
return netipx.IPRangeFrom(
|
||||||
netip.MustParseAddr("100.64.0."+from),
|
netip.MustParseAddr("100.64.0."+from),
|
||||||
@@ -706,11 +802,13 @@ func makeDNSResponseForSections(t *testing.T, questions []dnsmessage.Question, a
|
|||||||
|
|
||||||
func TestMapDNSResponseAssignsAddrs(t *testing.T) {
|
func TestMapDNSResponseAssignsAddrs(t *testing.T) {
|
||||||
for _, tt := range []struct {
|
for _, tt := range []struct {
|
||||||
name string
|
name string
|
||||||
domain string
|
domain string
|
||||||
v4Addrs []*dnsmessage.AResource
|
v4Addrs []*dnsmessage.AResource
|
||||||
v6Addrs []*dnsmessage.AAAAResource
|
v6Addrs []*dnsmessage.AAAAResource
|
||||||
wantByMagicIP map[netip.Addr]addrs
|
selfTags []string
|
||||||
|
isEligibleConnector bool
|
||||||
|
wantByMagicIP map[netip.Addr]addrs
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "one-ip-matches",
|
name: "one-ip-matches",
|
||||||
@@ -783,6 +881,48 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
|
|||||||
{A: [4]byte{2, 0, 0, 0}},
|
{A: [4]byte{2, 0, 0, 0}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "no-rewrite-self-routed-domain",
|
||||||
|
domain: "example.com.",
|
||||||
|
v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
|
||||||
|
selfTags: []string{"tag:woo"},
|
||||||
|
isEligibleConnector: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rewrite-tagged-but-not-eligible-connector",
|
||||||
|
domain: "example.com.",
|
||||||
|
v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
|
||||||
|
selfTags: []string{"tag:woo"},
|
||||||
|
// isEligibleConnector is false: tag matches but prefs not set,
|
||||||
|
// so DNS response should be rewritten normally.
|
||||||
|
wantByMagicIP: map[netip.Addr]addrs{
|
||||||
|
netip.MustParseAddr("100.64.0.0"): {
|
||||||
|
domain: "example.com.",
|
||||||
|
dst: netip.MustParseAddr("1.0.0.0"),
|
||||||
|
magic: netip.MustParseAddr("100.64.0.0"),
|
||||||
|
transit: netip.MustParseAddr("100.64.0.40"),
|
||||||
|
app: "app1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rewrite-eligible-connector-no-matching-tag",
|
||||||
|
domain: "example.com.",
|
||||||
|
v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}},
|
||||||
|
selfTags: []string{"tag:unrelated"},
|
||||||
|
isEligibleConnector: true,
|
||||||
|
// isEligibleConnector is true but tag doesn't match the app,
|
||||||
|
// so DNS response should be rewritten normally.
|
||||||
|
wantByMagicIP: map[netip.Addr]addrs{
|
||||||
|
netip.MustParseAddr("100.64.0.0"): {
|
||||||
|
domain: "example.com.",
|
||||||
|
dst: netip.MustParseAddr("1.0.0.0"),
|
||||||
|
magic: netip.MustParseAddr("100.64.0.0"),
|
||||||
|
transit: netip.MustParseAddr("100.64.0.40"),
|
||||||
|
app: "app1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
var dnsResp []byte
|
var dnsResp []byte
|
||||||
@@ -799,9 +939,15 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
|
|||||||
V6MagicIPPool: []netipx.IPRange{v6RangeFrom("0", "10"), v6RangeFrom("20", "30")},
|
V6MagicIPPool: []netipx.IPRange{v6RangeFrom("0", "10"), v6RangeFrom("20", "30")},
|
||||||
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")},
|
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")},
|
||||||
V6TransitIPPool: []netipx.IPRange{v6RangeFrom("40", "50")},
|
V6TransitIPPool: []netipx.IPRange{v6RangeFrom("40", "50")},
|
||||||
}}, []string{})
|
}}, tt.selfTags)
|
||||||
|
prefs := testPrefsNotConnector
|
||||||
|
if tt.isEligibleConnector {
|
||||||
|
prefs = testPrefsIsConnector
|
||||||
|
}
|
||||||
|
|
||||||
c := newConn25(logger.Discard)
|
c := newConn25(logger.Discard)
|
||||||
c.reconfig(sn)
|
cfg := mustConfig(t, sn, prefs)
|
||||||
|
c.reconfig(cfg)
|
||||||
|
|
||||||
c.mapDNSResponse(dnsResp)
|
c.mapDNSResponse(dnsResp)
|
||||||
if diff := cmp.Diff(tt.wantByMagicIP, c.client.assignments.byMagicIP, cmpopts.EquateComparable(addrs{}, netip.Addr{})); diff != "" {
|
if diff := cmp.Diff(tt.wantByMagicIP, c.client.assignments.byMagicIP, cmpopts.EquateComparable(addrs{}, netip.Addr{})); diff != "" {
|
||||||
@@ -831,7 +977,7 @@ func TestReserveAddressesDeduplicated(t *testing.T) {
|
|||||||
c.client.v6MagicIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0100::/80"))
|
c.client.v6MagicIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0100::/80"))
|
||||||
c.client.v4TransitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24"))
|
c.client.v4TransitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24"))
|
||||||
c.client.v6TransitIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0200::/80"))
|
c.client.v6TransitIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0200::/80"))
|
||||||
c.client.config.appNamesByDomain = map[dnsname.FQDN][]string{"example.com.": {"a"}}
|
c.client.config.nv.appNamesByDomain = map[dnsname.FQDN][]string{"example.com.": {"a"}}
|
||||||
|
|
||||||
first, err := c.client.reserveAddresses("example.com.", tt.dst)
|
first, err := c.client.reserveAddresses("example.com.", tt.dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -880,16 +1026,25 @@ func (nb *testNodeBackend) PeerAPIBase(p tailcfg.NodeView) string {
|
|||||||
return nb.peerAPIURL
|
return nb.peerAPIURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testProfileServices struct {
|
||||||
|
ipnext.ProfileServices
|
||||||
|
prefs ipn.PrefsView
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *testProfileServices) CurrentPrefs() ipn.PrefsView { return p.prefs }
|
||||||
|
|
||||||
type testHost struct {
|
type testHost struct {
|
||||||
ipnext.Host
|
ipnext.Host
|
||||||
nb ipnext.NodeBackend
|
nb ipnext.NodeBackend
|
||||||
hooks ipnext.Hooks
|
hooks ipnext.Hooks
|
||||||
|
prefs ipn.PrefsView
|
||||||
authReconfigAsync func()
|
authReconfigAsync func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *testHost) NodeBackend() ipnext.NodeBackend { return h.nb }
|
func (h *testHost) NodeBackend() ipnext.NodeBackend { return h.nb }
|
||||||
func (h *testHost) Hooks() *ipnext.Hooks { return &h.hooks }
|
func (h *testHost) Hooks() *ipnext.Hooks { return &h.hooks }
|
||||||
func (h *testHost) AuthReconfigAsync() { h.authReconfigAsync() }
|
func (h *testHost) Profiles() ipnext.ProfileServices { return &testProfileServices{prefs: h.prefs} }
|
||||||
|
func (h *testHost) AuthReconfigAsync() { h.authReconfigAsync() }
|
||||||
|
|
||||||
type testSafeBackend struct {
|
type testSafeBackend struct {
|
||||||
ipnext.SafeBackend
|
ipnext.SafeBackend
|
||||||
@@ -950,6 +1105,7 @@ func TestAddressAssignmentIsHandled(t *testing.T) {
|
|||||||
peers: []tailcfg.NodeView{connectorPeer},
|
peers: []tailcfg.NodeView{connectorPeer},
|
||||||
peerAPIURL: peersAPI.URL,
|
peerAPIURL: peersAPI.URL,
|
||||||
},
|
},
|
||||||
|
prefs: testPrefsNotConnector,
|
||||||
authReconfigAsync: func() {
|
authReconfigAsync: func() {
|
||||||
authReconfigAsyncCalled <- struct{}{}
|
authReconfigAsyncCalled <- struct{}{}
|
||||||
},
|
},
|
||||||
@@ -963,10 +1119,9 @@ func TestAddressAssignmentIsHandled(t *testing.T) {
|
|||||||
Connectors: []string{"tag:woo"},
|
Connectors: []string{"tag:woo"},
|
||||||
Domains: []string{"example.com"},
|
Domains: []string{"example.com"},
|
||||||
}}, []string{})
|
}}, []string{})
|
||||||
err := ext.conn25.reconfig(sn)
|
|
||||||
if err != nil {
|
cfg := mustConfig(t, sn, testPrefsNotConnector)
|
||||||
t.Fatal(err)
|
ext.conn25.reconfig(cfg)
|
||||||
}
|
|
||||||
|
|
||||||
as := addrs{
|
as := addrs{
|
||||||
dst: netip.MustParseAddr("1.2.3.4"),
|
dst: netip.MustParseAddr("1.2.3.4"),
|
||||||
@@ -1046,6 +1201,8 @@ func TestMapDNSResponseRewritesResponses(t *testing.T) {
|
|||||||
V6TransitIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6813:100"), netip.MustParseAddr("2606:4700::6813:1ff"))},
|
V6TransitIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6813:100"), netip.MustParseAddr("2606:4700::6813:1ff"))},
|
||||||
}}, []string{})
|
}}, []string{})
|
||||||
|
|
||||||
|
cfg := mustConfig(t, sn, testPrefsNotConnector)
|
||||||
|
|
||||||
compareToRecords := func(t *testing.T, resources []dnsmessage.Resource, want []netip.Addr) {
|
compareToRecords := func(t *testing.T, resources []dnsmessage.Resource, want []netip.Addr) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var got []netip.Addr
|
var got []netip.Addr
|
||||||
@@ -1323,9 +1480,7 @@ func TestMapDNSResponseRewritesResponses(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
c := newConn25(logger.Discard)
|
c := newConn25(logger.Discard)
|
||||||
if err := c.reconfig(sn); err != nil {
|
c.reconfig(cfg)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
bs := c.mapDNSResponse(tt.toMap)
|
bs := c.mapDNSResponse(tt.toMap)
|
||||||
tt.assertFx(t, bs)
|
tt.assertFx(t, bs)
|
||||||
})
|
})
|
||||||
@@ -1376,6 +1531,7 @@ func TestHandleAddressAssignmentStoresTransitIPs(t *testing.T) {
|
|||||||
peers: connectorPeers,
|
peers: connectorPeers,
|
||||||
peerAPIURL: peersAPI.URL,
|
peerAPIURL: peersAPI.URL,
|
||||||
},
|
},
|
||||||
|
prefs: testPrefsNotConnector,
|
||||||
authReconfigAsync: func() {
|
authReconfigAsync: func() {
|
||||||
authReconfigAsyncCalled <- struct{}{}
|
authReconfigAsyncCalled <- struct{}{}
|
||||||
},
|
},
|
||||||
@@ -1396,10 +1552,9 @@ func TestHandleAddressAssignmentStoresTransitIPs(t *testing.T) {
|
|||||||
Domains: []string{"hoo.example.com"},
|
Domains: []string{"hoo.example.com"},
|
||||||
},
|
},
|
||||||
}, []string{})
|
}, []string{})
|
||||||
err := ext.conn25.reconfig(sn)
|
|
||||||
if err != nil {
|
cfg := mustConfig(t, sn, testPrefsNotConnector)
|
||||||
t.Fatal(err)
|
ext.conn25.reconfig(cfg)
|
||||||
}
|
|
||||||
|
|
||||||
type lookup struct {
|
type lookup struct {
|
||||||
connKey key.NodePublic
|
connKey key.NodePublic
|
||||||
@@ -1576,6 +1731,7 @@ func TestClientTransitIPForMagicIP(t *testing.T) {
|
|||||||
V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10")}, // 100.64.0.0 - 100.64.0.10
|
V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10")}, // 100.64.0.0 - 100.64.0.10
|
||||||
V6MagicIPPool: []netipx.IPRange{v6RangeFrom("0", "10")},
|
V6MagicIPPool: []netipx.IPRange{v6RangeFrom("0", "10")},
|
||||||
}}, []string{})
|
}}, []string{})
|
||||||
|
cfg := mustConfig(t, sn, testPrefsNotConnector)
|
||||||
|
|
||||||
mappedMip := netip.MustParseAddr("100.64.0.0")
|
mappedMip := netip.MustParseAddr("100.64.0.0")
|
||||||
mappedTip := netip.MustParseAddr("169.0.0.0")
|
mappedTip := netip.MustParseAddr("169.0.0.0")
|
||||||
@@ -1634,9 +1790,8 @@ func TestClientTransitIPForMagicIP(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
c := newConn25(t.Logf)
|
c := newConn25(t.Logf)
|
||||||
if err := c.reconfig(sn); err != nil {
|
c.reconfig(cfg)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := c.client.assignments.insert(addrs{
|
if err := c.client.assignments.insert(addrs{
|
||||||
magic: mappedMip,
|
magic: mappedMip,
|
||||||
transit: mappedTip,
|
transit: mappedTip,
|
||||||
@@ -1666,6 +1821,8 @@ func TestConnectorRealIPForTransitIPConnection(t *testing.T) {
|
|||||||
sn := makeSelfNode(t, []appctype.Conn25Attr{{
|
sn := makeSelfNode(t, []appctype.Conn25Attr{{
|
||||||
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")}, // 100.64.0.40 - 100.64.0.50
|
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")}, // 100.64.0.40 - 100.64.0.50
|
||||||
}}, []string{})
|
}}, []string{})
|
||||||
|
cfg := mustConfig(t, sn, testPrefsIsConnector)
|
||||||
|
|
||||||
mappedSrc := netip.MustParseAddr("100.0.0.1")
|
mappedSrc := netip.MustParseAddr("100.0.0.1")
|
||||||
unmappedSrc := netip.MustParseAddr("100.0.0.2")
|
unmappedSrc := netip.MustParseAddr("100.0.0.2")
|
||||||
mappedTip := netip.MustParseAddr("100.64.0.41")
|
mappedTip := netip.MustParseAddr("100.64.0.41")
|
||||||
@@ -1717,9 +1874,7 @@ func TestConnectorRealIPForTransitIPConnection(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
c := newConn25(t.Logf)
|
c := newConn25(t.Logf)
|
||||||
if err := c.reconfig(sn); err != nil {
|
c.reconfig(cfg)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
c.connector.transitIPs = map[netip.Addr]map[netip.Addr]appAddr{}
|
c.connector.transitIPs = map[netip.Addr]map[netip.Addr]appAddr{}
|
||||||
c.connector.transitIPs[mappedSrc] = map[netip.Addr]appAddr{}
|
c.connector.transitIPs[mappedSrc] = map[netip.Addr]appAddr{}
|
||||||
c.connector.transitIPs[mappedSrc][mappedTip] = appAddr{addr: mappedMip}
|
c.connector.transitIPs[mappedSrc][mappedTip] = appAddr{addr: mappedMip}
|
||||||
@@ -1812,10 +1967,9 @@ func TestGetMagicRange(t *testing.T) {
|
|||||||
V4MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("0.0.0.1"), netip.MustParseAddr("0.0.0.3"))},
|
V4MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("0.0.0.1"), netip.MustParseAddr("0.0.0.3"))},
|
||||||
V6MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("::1"), netip.MustParseAddr("::3"))},
|
V6MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("::1"), netip.MustParseAddr("::3"))},
|
||||||
}}, []string{})
|
}}, []string{})
|
||||||
|
cfg := mustConfig(t, sn, testPrefsNotConnector)
|
||||||
c := newConn25(t.Logf)
|
c := newConn25(t.Logf)
|
||||||
if err := c.reconfig(sn); err != nil {
|
c.reconfig(cfg)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ext := &extension{
|
ext := &extension{
|
||||||
conn25: c,
|
conn25: c,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -864,7 +864,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
|
|||||||
addSplitDNSRoutes(nm.DNS.Routes)
|
addSplitDNSRoutes(nm.DNS.Routes)
|
||||||
|
|
||||||
// Add split DNS routes for conn25
|
// Add split DNS routes for conn25
|
||||||
conn25DNSTargets := appc.PickSplitDNSPeers(nm.HasCap, nm.SelfNode, peers)
|
conn25DNSTargets := appc.PickSplitDNSPeers(nm.HasCap, nm.SelfNode, peers, prefs.AppConnector().Advertise)
|
||||||
if conn25DNSTargets != nil {
|
if conn25DNSTargets != nil {
|
||||||
var m map[string][]*dnstype.Resolver
|
var m map[string][]*dnstype.Resolver
|
||||||
for domain, candidateSplitDNSPeers := range conn25DNSTargets {
|
for domain, candidateSplitDNSPeers := range conn25DNSTargets {
|
||||||
|
|||||||
Reference in New Issue
Block a user