feature/conn25: set assignment expiry based on dns response TTL

Updates tailscale/corp#39975

Signed-off-by: Fran Bull <fran@tailscale.com>
This commit is contained in:
Fran Bull
2026-05-21 07:25:29 -07:00
committed by franbull
parent f3a117e813
commit dbe92f98b5
3 changed files with 107 additions and 10 deletions
+8
View File
@@ -38,6 +38,14 @@ func (a *addrAssignments) insert(as *addrs) error {
return a.insertWithExpiry(as, defaultExpiry)
}
func (a *addrAssignments) insertFromTTL(as *addrs, ttl time.Duration) error {
const minTTL = time.Minute * 1
const maxTTL = time.Hour * 72
expiry := max(minTTL, ttl)
expiry = min(maxTTL, expiry)
return a.insertWithExpiry(as, expiry)
}
func (a *addrAssignments) insertWithExpiry(as *addrs, d time.Duration) error {
now := a.clock.Now()
if !as.expiresAt.IsZero() && !as.expiresAt.Before(now) {
+5 -4
View File
@@ -748,7 +748,7 @@ func (cfg *config) getAppsForConnectorDomain(domain dnsname.FQDN, prefsAdvertise
// the app name refers to a configured app.
// It checks that this domain should be routed and that this client is not itself a connector for the domain
// and generally if it is valid to make the assignment.
func (c *client) reserveAddresses(appName string, domain dnsname.FQDN, dst netip.Addr) (*addrs, error) {
func (c *client) reserveAddresses(appName string, domain dnsname.FQDN, dst netip.Addr, ttl time.Duration) (*addrs, error) {
if !dst.IsValid() {
return nil, errors.New("dst is not valid")
}
@@ -810,7 +810,7 @@ func (c *client) reserveAddresses(appName string, domain dnsname.FQDN, dst netip
app: appName,
domain: domain,
}
if err := c.assignments.insert(as); err != nil {
if err := c.assignments.insertFromTTL(as, ttl); err != nil {
return nil, err
}
err = c.enqueueAddressAssignment(as)
@@ -952,6 +952,7 @@ func (e *extension) sendAddressAssignment(ctx context.Context, as addrs) (tailcf
type dnsResponseRewrite struct {
domain dnsname.FQDN
dst netip.Addr
ttl time.Duration
}
func makeServFail(logf logger.Logf, h dnsmessage.Header, q dnsmessage.Question) []byte {
@@ -1143,7 +1144,7 @@ func (c *Conn25) mapDNSResponse(buf []byte) []byte {
}
dstAddr = netip.AddrFrom16(r.AAAA)
}
answers = append(answers, dnsResponseRewrite{domain: queriedDomain, dst: dstAddr})
answers = append(answers, dnsResponseRewrite{domain: queriedDomain, dst: dstAddr, ttl: time.Second * time.Duration(h.TTL)})
default:
// we already checked the question was for a supported type, this answer is unexpected
c.logf("unexpected type for connector domain dns response: %v %v", queriedDomain, h.Type)
@@ -1178,7 +1179,7 @@ func (c *client) rewriteDNSResponse(appName string, hdr dnsmessage.Header, quest
// make an answer for each rewrite
for _, rw := range answers {
as, err := c.reserveAddresses(appName, rw.domain, rw.dst)
as, err := c.reserveAddresses(appName, rw.domain, rw.dst, rw.ttl)
if err != nil {
return nil, err
}
+94 -6
View File
@@ -26,6 +26,7 @@ import (
"tailscale.com/net/tstun"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/tstest"
"tailscale.com/types/appctype"
"tailscale.com/types/key"
"tailscale.com/types/logger"
@@ -428,7 +429,7 @@ func TestReserveIPs(t *testing.T) {
},
} {
t.Run(tt.name, func(t *testing.T) {
addrs, err := c.client.reserveAddresses(appName, domain, tt.dst)
addrs, err := c.client.reserveAddresses(appName, domain, tt.dst, 10)
if err != nil {
t.Fatal(err)
}
@@ -1122,6 +1123,93 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) {
}
}
func TestMapDNSResponseSetsExpiryBasedOnTTL(t *testing.T) {
configuredDomain := "example.com"
domainName := configuredDomain + "."
dnsMessageName := dnsmessage.MustNewName(domainName)
sn := makeSelfNode(t, []appctype.Conn25Attr{{
Name: "app1",
Connectors: []string{"tag:woo"},
Domains: []string{configuredDomain},
V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10")},
V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")},
V6MagicIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6812:100"), netip.MustParseAddr("2606:4700::6812:1ff"))},
V6TransitIPPool: []netipx.IPRange{netipx.IPRangeFrom(netip.MustParseAddr("2606:4700::6813:100"), netip.MustParseAddr("2606:4700::6813:1ff"))},
}}, nil)
c := newConn25(logger.Discard)
clock := tstest.NewClock(tstest.ClockOpts{Start: time.Now()})
c.client.assignments.clock = clock
cfg := mustConfig(t, sn)
c.reconfig(cfg)
ipOne := netip.MustParseAddr("1.0.0.1")
ipTwo := netip.MustParseAddr("1.0.0.2")
ipTooBig := netip.MustParseAddr("1.0.0.3")
ipTooSmall := netip.MustParseAddr("1.0.0.4")
dnsResp := makeDNSResponseForSections(t,
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
[]dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 300},
Body: &dnsmessage.AResource{A: ipOne.As4()},
},
{
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 60},
Body: &dnsmessage.AResource{A: ipTwo.As4()},
},
{
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 1000000},
Body: &dnsmessage.AResource{A: ipTooBig.As4()},
},
{
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 1},
Body: &dnsmessage.AResource{A: ipTooSmall.As4()},
},
},
nil,
)
c.mapDNSResponse(dnsResp)
assertExpiresAt := func(addr netip.Addr, want time.Time) {
t.Helper()
as, ok := c.client.assignments.lookupByDomainDst("example.com.", addr)
if !ok {
t.Fatal("setup wrong expected to find addrs")
}
got := as.expiresAt
if !got.Equal(want) {
t.Fatalf("want %v; got %v", want, got)
}
}
assertExpiresAt(ipOne, clock.Now().Add(300*time.Second))
assertExpiresAt(ipTwo, clock.Now().Add(60*time.Second))
assertExpiresAt(ipTooBig, clock.Now().Add(72*time.Hour)) // max we will do is 72 hours
assertExpiresAt(ipTooSmall, clock.Now().Add(60*time.Second)) // min we will do is 1 min
ipThree := netip.MustParseAddr("::1")
ipFour := netip.MustParseAddr("::2")
dnsRespV6 := makeDNSResponseForSections(t,
[]dnsmessage.Question{{Name: dnsMessageName, Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET}},
[]dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET, TTL: 301},
Body: &dnsmessage.AAAAResource{AAAA: ipThree.As16()},
},
{
Header: dnsmessage.ResourceHeader{Name: dnsMessageName, Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET, TTL: 61},
Body: &dnsmessage.AAAAResource{AAAA: ipFour.As16()},
},
},
nil,
)
c.mapDNSResponse(dnsRespV6)
assertExpiresAt(ipThree, clock.Now().Add(301*time.Second))
assertExpiresAt(ipFour, clock.Now().Add(61*time.Second))
}
func TestNormalizedDNSNames(t *testing.T) {
tests := []struct {
name string
@@ -1168,12 +1256,12 @@ func TestReserveAddressesDeduplicated(t *testing.T) {
c.v4TransitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24"))
c.v6TransitIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0200::/80"))
first, err := c.reserveAddresses(appName, "example.com.", tt.dst)
first, err := c.reserveAddresses(appName, "example.com.", tt.dst, 10)
if err != nil {
t.Fatal(err)
}
second, err := c.reserveAddresses(appName, "example.com.", tt.dst)
second, err := c.reserveAddresses(appName, "example.com.", tt.dst, 10)
if err != nil {
t.Fatal(err)
}
@@ -2414,18 +2502,18 @@ func TestReconfigDoesNotReissueInUseAddresses(t *testing.T) {
conn25: c,
}
_, err := c.client.reserveAddresses(appName, domain, tt.dstOne)
_, err := c.client.reserveAddresses(appName, domain, tt.dstOne, 10)
if !errors.Is(err, errUninitializedIPPool) {
t.Fatalf("want %v, got %v", errUninitializedIPPool, err)
}
ext.onSelfChange(makeNodeFromMagicRange(beforeRangeV4, beforeRangeV6))
beforeAddrs, err := c.client.reserveAddresses(appName, domain, tt.dstOne)
beforeAddrs, err := c.client.reserveAddresses(appName, domain, tt.dstOne, 10)
if err != nil {
t.Fatal(err)
}
ext.onSelfChange(makeNodeFromMagicRange(afterRangeV4, afterRangeV6))
afterAddrs, err := c.client.reserveAddresses(appName, domain, tt.dstTwo)
afterAddrs, err := c.client.reserveAddresses(appName, domain, tt.dstTwo, 10)
if err != nil {
t.Fatal(err)
}