From 96c3ad582b955ba94e29832873d9009f15a8ba96 Mon Sep 17 00:00:00 2001 From: Fran Bull Date: Mon, 6 Apr 2026 11:24:41 -0700 Subject: [PATCH] feature/conn25: add IPv6 support Make the DNS handling portions of conn25 work with IPv6 addresses. Fixes tailscale/corp#37850 Signed-off-by: Fran Bull --- feature/conn25/conn25.go | 133 +++++++++---- feature/conn25/conn25_test.go | 353 ++++++++++++++++++++++++++------- types/appctype/appconnector.go | 8 +- 3 files changed, 379 insertions(+), 115 deletions(-) diff --git a/feature/conn25/conn25.go b/feature/conn25/conn25.go index eeb02c5f8..e716c09d0 100644 --- a/feature/conn25/conn25.go +++ b/feature/conn25/conn25.go @@ -229,7 +229,7 @@ func (c *Conn25) ConnectorRealIPForTransitIPConnection(src, transit netip.Addr) func (e *extension) getMagicRange() views.Slice[netip.Prefix] { cfg := e.conn25.client.getConfig() - return views.SliceOf(cfg.magicIPSet.Prefixes()) + return views.SliceOf(slices.Concat(cfg.v4MagicIPSet.Prefixes(), cfg.v6MagicIPSet.Prefixes())) } // Shutdown implements [ipnlocal.Extension]. @@ -493,8 +493,10 @@ type config struct { appsByName map[string]appctype.Conn25Attr appNamesByDomain map[dnsname.FQDN][]string selfRoutedDomains set.Set[dnsname.FQDN] - transitIPSet netipx.IPSet - magicIPSet netipx.IPSet + v4TransitIPSet netipx.IPSet + v4MagicIPSet netipx.IPSet + v6TransitIPSet netipx.IPSet + v6MagicIPSet netipx.IPSet } func configFromNodeView(n tailcfg.NodeView) (config, error) { @@ -531,16 +533,26 @@ func configFromNodeView(n tailcfg.NodeView) (config, error) { // global IP pool config. For now just take it from the first app. if len(apps) != 0 { app := apps[0] - mipp, err := ipSetFromIPRanges(app.MagicIPPool) + v4Mipp, err := ipSetFromIPRanges(app.V4MagicIPPool) if err != nil { return config{}, err } - tipp, err := ipSetFromIPRanges(app.TransitIPPool) + v4Tipp, err := ipSetFromIPRanges(app.V4TransitIPPool) if err != nil { return config{}, err } - cfg.magicIPSet = *mipp - cfg.transitIPSet = *tipp + v6Mipp, err := ipSetFromIPRanges(app.V6MagicIPPool) + if err != nil { + return config{}, err + } + v6Tipp, err := ipSetFromIPRanges(app.V6TransitIPPool) + if err != nil { + return config{}, err + } + cfg.v4MagicIPSet = *v4Mipp + cfg.v4TransitIPSet = *v4Tipp + cfg.v6MagicIPSet = *v6Mipp + cfg.v6TransitIPSet = *v6Tipp } return cfg, nil } @@ -553,11 +565,13 @@ type client struct { logf logger.Logf addrsCh chan addrs - mu sync.Mutex // protects the fields below - magicIPPool *ippool - transitIPPool *ippool - assignments addrAssignments - config config + mu sync.Mutex // protects the fields below + v4MagicIPPool *ippool + v4TransitIPPool *ippool + v6MagicIPPool *ippool + v6TransitIPPool *ippool + assignments addrAssignments + config config } func (c *client) getConfig() config { @@ -575,7 +589,7 @@ func (c *client) transitIPForMagicIP(magicIP netip.Addr) (netip.Addr, error) { if ok { return v.transit, nil } - if !c.config.magicIPSet.Contains(magicIP) { + if !c.config.v4MagicIPSet.Contains(magicIP) && !c.config.v6MagicIPSet.Contains(magicIP) { return netip.Addr{}, nil } return netip.Addr{}, ErrUnmappedMagicIP @@ -613,8 +627,10 @@ func (c *client) reconfig(newCfg config) error { c.config = newCfg - c.magicIPPool = newIPPool(&(newCfg.magicIPSet)) - c.transitIPPool = newIPPool(&(newCfg.transitIPSet)) + c.v4MagicIPPool = newIPPool(&(newCfg.v4MagicIPSet)) + c.v4TransitIPPool = newIPPool(&(newCfg.v4TransitIPSet)) + c.v6MagicIPPool = newIPPool(&(newCfg.v6MagicIPSet)) + c.v6TransitIPPool = newIPPool(&(newCfg.v6TransitIPSet)) return nil } @@ -630,6 +646,9 @@ func (c *client) isConnectorDomain(domain dnsname.FQDN) bool { // 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(domain dnsname.FQDN, dst netip.Addr) (addrs, error) { + if !dst.IsValid() { + return addrs{}, errors.New("dst is not valid") + } c.mu.Lock() defer c.mu.Unlock() if existing, ok := c.assignments.lookupByDomainDst(domain, dst); ok { @@ -641,13 +660,29 @@ func (c *client) reserveAddresses(domain dnsname.FQDN, dst netip.Addr) (addrs, e } // only reserve for first app app := appNames[0] - mip, err := c.magicIPPool.next() - if err != nil { - return addrs{}, err - } - tip, err := c.transitIPPool.next() - if err != nil { - return addrs{}, err + + var mip, tip netip.Addr + var err error + if dst.Is4() { + mip, err = c.v4MagicIPPool.next() + if err != nil { + return addrs{}, err + } + tip, err = c.v4TransitIPPool.next() + if err != nil { + return addrs{}, err + } + } else if dst.Is6() { + mip, err = c.v6MagicIPPool.next() + if err != nil { + return addrs{}, err + } + tip, err = c.v6TransitIPPool.next() + if err != nil { + return addrs{}, err + } + } else { + return addrs{}, errors.New("unexpected neither 4 nor 6") } as := addrs{ dst: dst, @@ -856,8 +891,7 @@ func (c *client) mapDNSResponse(buf []byte) []byte { // * not send through the additional section // * provide our answers, or no answers if we don't handle those answers (possibly in the future we should write through answers for eg TypeTXT) var answers []dnsResponseRewrite - if question.Type != dnsmessage.TypeA { - // we plan to support TypeAAAA soon (2026-03-11) + if question.Type != dnsmessage.TypeA && question.Type != dnsmessage.TypeAAAA { c.logf("mapping dns response for connector domain, unsupported type: %v", question.Type) newBuf, err := c.rewriteDNSResponse(hdr, questions, answers) if err != nil { @@ -895,7 +929,15 @@ func (c *client) mapDNSResponse(buf []byte) []byte { c.logf("error parsing dns response: %v", err) return makeServFail(c.logf, hdr, question) } - case dnsmessage.TypeA: + case dnsmessage.TypeA, dnsmessage.TypeAAAA: + if h.Type != question.Type { + // would not expect a v4 response to a v6 question or vice versa, don't add a rewrite for this. + if err := p.SkipAnswer(); err != nil { + c.logf("error parsing dns response: %v", err) + return makeServFail(c.logf, hdr, question) + } + continue + } domain, err := normalizeDNSName(h.Name.String()) if err != nil { c.logf("bad dnsname: %v", err) @@ -910,12 +952,23 @@ func (c *client) mapDNSResponse(buf []byte) []byte { } continue } - r, err := p.AResource() - if err != nil { - c.logf("error parsing dns response: %v", err) - return makeServFail(c.logf, hdr, question) + var dstAddr netip.Addr + if h.Type == dnsmessage.TypeA { + r, err := p.AResource() + if err != nil { + c.logf("error parsing dns response: %v", err) + return makeServFail(c.logf, hdr, question) + } + dstAddr = netip.AddrFrom4(r.A) + } else { + r, err := p.AAAAResource() + if err != nil { + c.logf("error parsing dns response: %v", err) + return makeServFail(c.logf, hdr, question) + } + dstAddr = netip.AddrFrom16(r.AAAA) } - answers = append(answers, dnsResponseRewrite{domain: domain, dst: netip.AddrFrom4(r.A)}) + answers = append(answers, dnsResponseRewrite{domain: domain, dst: dstAddr}) 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) @@ -934,8 +987,6 @@ func (c *client) mapDNSResponse(buf []byte) []byte { } func (c *client) rewriteDNSResponse(hdr dnsmessage.Header, questions []dnsmessage.Question, answers []dnsResponseRewrite) ([]byte, error) { - // We are currently (2026-03-10) only doing this for AResource records, we know that if we are here - // with non-empty answers, the type was AResource. b := dnsmessage.NewBuilder(nil, hdr) b.EnableCompression() if err := b.StartQuestions(); err != nil { @@ -963,10 +1014,18 @@ func (c *client) rewriteDNSResponse(hdr dnsmessage.Header, questions []dnsmessag if err != nil { return nil, err } - // only handling TypeA right now - rhdr := dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 0} - if err := b.AResource(rhdr, dnsmessage.AResource{A: as.magic.As4()}); err != nil { - return nil, err + if rw.dst.Is4() { + rhdr := dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 0} + if err := b.AResource(rhdr, dnsmessage.AResource{A: as.magic.As4()}); err != nil { + return nil, err + } + } else if rw.dst.Is6() { + rhdr := dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET, TTL: 0} + if err := b.AAAAResource(rhdr, dnsmessage.AAAAResource{AAAA: as.magic.As16()}); err != nil { + return nil, err + } + } else { + return nil, errors.New("unexpected neither 4 nor 6") } } // We do _not_ include the additional section in our rewrite. (We don't want to include @@ -997,7 +1056,7 @@ func (c *connector) realIPForTransitIPConnection(srcIP netip.Addr, transitIP net if ok { return v.addr, nil } - if !c.config.transitIPSet.Contains(transitIP) { + if !c.config.v4TransitIPSet.Contains(transitIP) && !c.config.v6TransitIPSet.Contains(transitIP) { return netip.Addr{}, nil } return netip.Addr{}, ErrUnmappedSrcAndTransitIP diff --git a/feature/conn25/conn25_test.go b/feature/conn25/conn25_test.go index 0a90c151a..5f136c556 100644 --- a/feature/conn25/conn25_test.go +++ b/feature/conn25/conn25_test.go @@ -390,38 +390,57 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) { func TestReserveIPs(t *testing.T) { c := newConn25(logger.Discard) - c.client.magicIPPool = newIPPool(mustIPSetFromPrefix("100.64.0.0/24")) - c.client.transitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24")) + c.client.v4MagicIPPool = newIPPool(mustIPSetFromPrefix("100.64.0.0/24")) + c.client.v6MagicIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0100::/80")) + c.client.v4TransitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24")) + c.client.v6TransitIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0200::/80")) + app := "a" + domainStr := "example.com." mbd := map[dnsname.FQDN][]string{} - mbd["example.com."] = []string{"a"} + mbd["example.com."] = []string{app} c.client.config.appNamesByDomain = mbd + domain := must.Get(dnsname.ToFQDN(domainStr)) - dst := netip.MustParseAddr("0.0.0.1") - addrs, err := c.client.reserveAddresses("example.com.", dst) - if err != nil { - t.Fatal(err) - } - - wantDst := netip.MustParseAddr("0.0.0.1") // same as dst we pass in - wantMagic := netip.MustParseAddr("100.64.0.0") // first from magic pool - wantTransit := netip.MustParseAddr("169.254.0.0") // first from transit pool - wantApp := "a" // the app name related to example.com. - wantDomain := must.Get(dnsname.ToFQDN("example.com.")) - - if wantDst != addrs.dst { - t.Errorf("want %v, got %v", wantDst, addrs.dst) - } - if wantMagic != addrs.magic { - t.Errorf("want %v, got %v", wantMagic, addrs.magic) - } - if wantTransit != addrs.transit { - t.Errorf("want %v, got %v", wantTransit, addrs.transit) - } - if wantApp != addrs.app { - t.Errorf("want %s, got %s", wantApp, addrs.app) - } - if wantDomain != addrs.domain { - t.Errorf("want %s, got %s", wantDomain, addrs.domain) + for _, tt := range []struct { + name string + dst netip.Addr + wantMagic netip.Addr + wantTransit netip.Addr + }{ + { + name: "v4", + dst: netip.MustParseAddr("0.0.0.1"), + wantMagic: netip.MustParseAddr("100.64.0.0"), // first from magic pool + wantTransit: netip.MustParseAddr("169.254.0.0"), // first from transit pool + }, + { + name: "v6", + dst: netip.MustParseAddr("::1"), + wantMagic: netip.MustParseAddr("fd7a:115c:a1e0:a99c:100::"), // first from magic pool + wantTransit: netip.MustParseAddr("fd7a:115c:a1e0:a99c:200::"), // first from transit pool + }, + } { + t.Run(tt.name, func(t *testing.T) { + addrs, err := c.client.reserveAddresses(domain, tt.dst) + if err != nil { + t.Fatal(err) + } + if tt.dst != addrs.dst { + t.Errorf("want %v, got %v", tt.dst, addrs.dst) + } + if tt.wantMagic != addrs.magic { + t.Errorf("want %v, got %v", tt.wantMagic, addrs.magic) + } + if tt.wantTransit != addrs.transit { + t.Errorf("want %v, got %v", tt.wantTransit, addrs.transit) + } + if app != addrs.app { + t.Errorf("want %s, got %s", app, addrs.app) + } + if domain != addrs.domain { + t.Errorf("want %s, got %s", domain, addrs.domain) + } + }) } } @@ -549,13 +568,20 @@ func makeSelfNode(t *testing.T, attrs []appctype.Conn25Attr, tags []string) tail }).View() } -func rangeFrom(from, to string) netipx.IPRange { +func v4RangeFrom(from, to string) netipx.IPRange { return netipx.IPRangeFrom( netip.MustParseAddr("100.64.0."+from), netip.MustParseAddr("100.64.0."+to), ) } +func v6RangeFrom(from, to string) netipx.IPRange { + return netipx.IPRangeFrom( + netip.MustParseAddr("fd7a:115c:a1e0:a99c:"+from+"::"), + netip.MustParseAddr("fd7a:115c:a1e0:a99c:"+to+"::"), + ) +} + func makeDNSResponse(t *testing.T, domain string, addrs []*dnsmessage.AResource) []byte { t.Helper() name := dnsmessage.MustNewName(domain) @@ -682,13 +708,14 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) { for _, tt := range []struct { name string domain string - addrs []*dnsmessage.AResource + v4Addrs []*dnsmessage.AResource + v6Addrs []*dnsmessage.AAAAResource wantByMagicIP map[netip.Addr]addrs }{ { - name: "one-ip-matches", - domain: "example.com.", - addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}}, + name: "one-ip-matches", + domain: "example.com.", + v4Addrs: []*dnsmessage.AResource{{A: [4]byte{1, 0, 0, 0}}}, // these are 'expected' because they are the beginning of the provided pools wantByMagicIP: map[netip.Addr]addrs{ netip.MustParseAddr("100.64.0.0"): { @@ -700,10 +727,34 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) { }, }, }, + { + name: "v6-ip-matches", + domain: "example.com.", + v6Addrs: []*dnsmessage.AAAAResource{ + {AAAA: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}}, + {AAAA: [16]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}}, + }, + wantByMagicIP: map[netip.Addr]addrs{ + netip.MustParseAddr("fd7a:115c:a1e0:a99c::"): { + domain: "example.com.", + dst: netip.MustParseAddr("::1"), + magic: netip.MustParseAddr("fd7a:115c:a1e0:a99c:0::"), + transit: netip.MustParseAddr("fd7a:115c:a1e0:a99c:40::"), + app: "app1", + }, + netip.MustParseAddr("fd7a:115c:a1e0:a99c::1"): { + domain: "example.com.", + dst: netip.MustParseAddr("::2"), + magic: netip.MustParseAddr("fd7a:115c:a1e0:a99c:0::1"), + transit: netip.MustParseAddr("fd7a:115c:a1e0:a99c:40::1"), + app: "app1", + }, + }, + }, { name: "multiple-ip-matches", domain: "example.com.", - addrs: []*dnsmessage.AResource{ + v4Addrs: []*dnsmessage.AResource{ {A: [4]byte{1, 0, 0, 0}}, {A: [4]byte{2, 0, 0, 0}}, }, @@ -727,20 +778,27 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) { { name: "no-domain-match", domain: "x.example.com.", - addrs: []*dnsmessage.AResource{ + v4Addrs: []*dnsmessage.AResource{ {A: [4]byte{1, 0, 0, 0}}, {A: [4]byte{2, 0, 0, 0}}, }, }, } { t.Run(tt.name, func(t *testing.T) { - dnsResp := makeDNSResponse(t, tt.domain, tt.addrs) + var dnsResp []byte + if len(tt.v4Addrs) > 0 { + dnsResp = makeDNSResponse(t, tt.domain, tt.v4Addrs) + } else { + dnsResp = makeV6DNSResponse(t, tt.domain, tt.v6Addrs) + } sn := makeSelfNode(t, []appctype.Conn25Attr{{ - Name: "app1", - Connectors: []string{"tag:woo"}, - Domains: []string{"example.com"}, - MagicIPPool: []netipx.IPRange{rangeFrom("0", "10"), rangeFrom("20", "30")}, - TransitIPPool: []netipx.IPRange{rangeFrom("40", "50")}, + Name: "app1", + Connectors: []string{"tag:woo"}, + Domains: []string{"example.com"}, + V4MagicIPPool: []netipx.IPRange{v4RangeFrom("0", "10"), v4RangeFrom("20", "30")}, + V6MagicIPPool: []netipx.IPRange{v6RangeFrom("0", "10"), v6RangeFrom("20", "30")}, + V4TransitIPPool: []netipx.IPRange{v4RangeFrom("40", "50")}, + V6TransitIPPool: []netipx.IPRange{v6RangeFrom("40", "50")}, }}, []string{}) c := newConn25(logger.Discard) c.reconfig(sn) @@ -754,30 +812,48 @@ func TestMapDNSResponseAssignsAddrs(t *testing.T) { } func TestReserveAddressesDeduplicated(t *testing.T) { - c := newConn25(logger.Discard) - c.client.magicIPPool = newIPPool(mustIPSetFromPrefix("100.64.0.0/24")) - c.client.transitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24")) - c.client.config.appNamesByDomain = map[dnsname.FQDN][]string{"example.com.": {"a"}} + for _, tt := range []struct { + name string + dst netip.Addr + }{ + { + name: "v4", + dst: netip.MustParseAddr("0.0.0.1"), + }, + { + name: "v6", + dst: netip.MustParseAddr("::1"), + }, + } { + t.Run(tt.name, func(t *testing.T) { + c := newConn25(logger.Discard) + c.client.v4MagicIPPool = newIPPool(mustIPSetFromPrefix("100.64.0.0/24")) + c.client.v6MagicIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0100::/80")) + c.client.v4TransitIPPool = newIPPool(mustIPSetFromPrefix("169.254.0.0/24")) + c.client.v6TransitIPPool = newIPPool(mustIPSetFromPrefix("fd7a:115c:a1e0:a99c:0200::/80")) + c.client.config.appNamesByDomain = map[dnsname.FQDN][]string{"example.com.": {"a"}} + + first, err := c.client.reserveAddresses("example.com.", tt.dst) + if err != nil { + t.Fatal(err) + } - dst := netip.MustParseAddr("0.0.0.1") - first, err := c.client.reserveAddresses("example.com.", dst) - if err != nil { - t.Fatal(err) - } + second, err := c.client.reserveAddresses("example.com.", tt.dst) + if err != nil { + t.Fatal(err) + } - second, err := c.client.reserveAddresses("example.com.", dst) - if err != nil { - t.Fatal(err) - } + if first != second { + t.Errorf("expected same addrs on repeated call, got first=%v second=%v", first, second) + } + if got := len(c.client.assignments.byMagicIP); got != 1 { + t.Errorf("want 1 entry in byMagicIP, got %d", got) + } + if got := len(c.client.assignments.byDomainDst); got != 1 { + t.Errorf("want 1 entry in byDomainDst, got %d", got) + } - if first != second { - t.Errorf("expected same addrs on repeated call, got first=%v second=%v", first, second) - } - if got := len(c.client.assignments.byMagicIP); got != 1 { - t.Errorf("want 1 entry in byMagicIP, got %d", got) - } - if got := len(c.client.assignments.byDomainDst); got != 1 { - t.Errorf("want 1 entry in byDomainDst, got %d", got) + }) } } @@ -961,11 +1037,13 @@ func TestMapDNSResponseRewritesResponses(t *testing.T) { domainName := configuredDomain + "." dnsMessageName := dnsmessage.MustNewName(domainName) sn := makeSelfNode(t, []appctype.Conn25Attr{{ - Name: "app1", - Connectors: []string{"tag:connector"}, - Domains: []string{configuredDomain}, - MagicIPPool: []netipx.IPRange{rangeFrom("0", "10")}, - TransitIPPool: []netipx.IPRange{rangeFrom("40", "50")}, + Name: "app1", + Connectors: []string{"tag:connector"}, + 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"))}, }}, []string{}) compareToRecords := func(t *testing.T, resources []dnsmessage.Resource, want []netip.Addr) { @@ -1060,12 +1138,17 @@ func TestMapDNSResponseRewritesResponses(t *testing.T) { ), }, { - name: "ipv6-no-answers", + name: "ipv6-multiple", toMap: makeV6DNSResponse(t, domainName, []*dnsmessage.AAAAResource{ {AAAA: netip.MustParseAddr("2606:4700::6812:1a78").As16()}, {AAAA: netip.MustParseAddr("2606:4700::6812:1b78").As16()}, }), - assertFx: assertParsesToAnswers(nil), + assertFx: assertParsesToAnswers( + []netip.Addr{ + netip.MustParseAddr("2606:4700::6812:100"), + netip.MustParseAddr("2606:4700::6812:101"), + }, + ), }, { name: "not-our-domain", @@ -1174,7 +1257,7 @@ func TestMapDNSResponseRewritesResponses(t *testing.T) { assertFx: assertParsesToAnswers(nil), }, { - name: "answer-type-mismatch", + name: "answer-type-mismatch-want-v4", toMap: makeDNSResponseForSections(t, []dnsmessage.Question{ { @@ -1205,6 +1288,38 @@ func TestMapDNSResponseRewritesResponses(t *testing.T) { ), assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("100.64.0.0")}), }, + { + name: "answer-type-mismatch-want-v6", + toMap: 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, + }, + Body: &dnsmessage.AAAAResource{AAAA: netip.MustParseAddr("1.2.3.4").As16()}, + }, + { + Header: dnsmessage.ResourceHeader{ + Name: dnsMessageName, + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + }, + Body: &dnsmessage.AResource{A: netip.MustParseAddr("5.6.7.8").As4()}, + }, + }, + nil, + ), + assertFx: assertParsesToAnswers([]netip.Addr{netip.MustParseAddr("2606:4700::6812:100")}), + }, } { t.Run(tt.name, func(t *testing.T) { c := newConn25(logger.Discard) @@ -1458,12 +1573,22 @@ func TestTransitIPConnMapping(t *testing.T) { func TestClientTransitIPForMagicIP(t *testing.T) { sn := makeSelfNode(t, []appctype.Conn25Attr{{ - MagicIPPool: []netipx.IPRange{rangeFrom("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")}, }}, []string{}) + mappedMip := netip.MustParseAddr("100.64.0.0") mappedTip := netip.MustParseAddr("169.0.0.0") unmappedMip := netip.MustParseAddr("100.64.0.1") nonMip := netip.MustParseAddr("100.64.0.11") + dst := netip.MustParseAddr("0.0.0.1") + + v6MappedMip := netip.MustParseAddr("fd7a:115c:a1e0:a99c:0::") + v6MappedTip := netip.MustParseAddr("fd7a:115c:a1e0:a99c:100::") + v6UnmappedMip := netip.MustParseAddr("fd7a:115c:a1e0:a99c:1::") + v6NonMip := netip.MustParseAddr("fd7a:115c:a1e0:a99c:11::") + v6Dst := netip.MustParseAddr("::1") + for _, tt := range []struct { name string mip netip.Addr @@ -1488,16 +1613,44 @@ func TestClientTransitIPForMagicIP(t *testing.T) { wantTip: mappedTip, wantErr: nil, }, + { + name: "v6-not-magic", + mip: v6NonMip, + wantTip: netip.Addr{}, + wantErr: nil, + }, + { + name: "v6-unmapped-magic-ip", + mip: v6UnmappedMip, + wantTip: netip.Addr{}, + wantErr: ErrUnmappedMagicIP, + }, + { + name: "v6-mapped-magic-ip", + mip: v6MappedMip, + wantTip: v6MappedTip, + wantErr: nil, + }, } { t.Run(tt.name, func(t *testing.T) { c := newConn25(t.Logf) if err := c.reconfig(sn); err != nil { t.Fatal(err) } - c.client.assignments.insert(addrs{ + if err := c.client.assignments.insert(addrs{ magic: mappedMip, transit: mappedTip, - }) + dst: dst, + }); err != nil { + t.Fatal(err) + } + if err := c.client.assignments.insert(addrs{ + magic: v6MappedMip, + transit: v6MappedTip, + dst: v6Dst, + }); err != nil { + t.Fatal(err) + } tip, err := c.client.transitIPForMagicIP(tt.mip) if tip != tt.wantTip { t.Fatalf("checking transit ip: want %v, got %v", tt.wantTip, tip) @@ -1511,7 +1664,7 @@ func TestClientTransitIPForMagicIP(t *testing.T) { func TestConnectorRealIPForTransitIPConnection(t *testing.T) { sn := makeSelfNode(t, []appctype.Conn25Attr{{ - TransitIPPool: []netipx.IPRange{rangeFrom("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{}) mappedSrc := netip.MustParseAddr("100.0.0.1") unmappedSrc := netip.MustParseAddr("100.0.0.2") @@ -1650,3 +1803,53 @@ func TestConnectorPacketFilterAllow(t *testing.T) { t.Fatal("unknownTip: should not have been allowed") } } + +func TestGetMagicRange(t *testing.T) { + sn := makeSelfNode(t, []appctype.Conn25Attr{{ + Name: "app1", + Connectors: []string{"tag:woo"}, + Domains: []string{"example.com"}, + 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"))}, + }}, []string{}) + c := newConn25(t.Logf) + if err := c.reconfig(sn); err != nil { + t.Fatal(err) + } + ext := &extension{ + conn25: c, + } + mRange := ext.getMagicRange() + somePrefixCovers := func(a netip.Addr) bool { + for _, r := range mRange.All() { + if r.Contains(a) { + return true + } + } + return false + } + ins := []string{ + "0.0.0.1", + "0.0.0.2", + "0.0.0.3", + "::1", + "::2", + "::3", + } + outs := []string{ + "0.0.0.0", + "0.0.0.4", + "::", + "::4", + } + for _, s := range ins { + if !somePrefixCovers(netip.MustParseAddr(s)) { + t.Fatalf("expected addr to be covered but was not: %s", s) + } + } + for _, s := range outs { + if somePrefixCovers(netip.MustParseAddr(s)) { + t.Fatalf("expected addr to NOT be covered but WAS: %s", s) + } + } +} diff --git a/types/appctype/appconnector.go b/types/appctype/appconnector.go index 0af5db4c3..b0fd5e65a 100644 --- a/types/appctype/appconnector.go +++ b/types/appctype/appconnector.go @@ -104,7 +104,9 @@ type Conn25Attr struct { // Connectors enumerates the app connectors which service these domains. // These can either be "*" to match any advertising connector, or a // tag of the form tag:. - Connectors []string `json:"connectors,omitempty"` - MagicIPPool []netipx.IPRange `json:"magicIPPool,omitempty"` - TransitIPPool []netipx.IPRange `json:"transitIPPool,omitempty"` + Connectors []string `json:"connectors,omitempty"` + V4MagicIPPool []netipx.IPRange `json:"v4MagicIPPool,omitempty"` + V4TransitIPPool []netipx.IPRange `json:"v4TransitIPPool,omitempty"` + V6MagicIPPool []netipx.IPRange `json:"v6MagicIPPool,omitempty"` + V6TransitIPPool []netipx.IPRange `json:"v6TransitIPPool,omitempty"` }