diff --git a/appc/conn25.go b/appc/conn25.go index b4890c26c..d0df65ae7 100644 --- a/appc/conn25.go +++ b/appc/conn25.go @@ -7,14 +7,124 @@ import ( "net/netip" "sync" + "go4.org/netipx" + "golang.org/x/net/dns/dnsmessage" "tailscale.com/tailcfg" ) +var tsMBox = dnsmessage.MustNewName("support.tailscale.com.") + +type appAddr struct { + app string + addr netip.Addr +} + // Conn25 holds the developing state for the as yet nascent next generation app connector. // There is currently (2025-12-08) no actual app connecting functionality. type Conn25 struct { - mu sync.Mutex + magicIPPool ippool // should not be mutated + transitIPPool ippool // should not be mutated + + mu sync.Mutex + // map of peer -> (map of transitip -> dst ip) transitIPs map[tailcfg.NodeID]map[netip.Addr]netip.Addr + // map of peer -> (map of magicip -> appAddr of dst ip) + magicIPs map[tailcfg.NodeID]map[netip.Addr]appAddr +} + +func NewConn25(magicPool, transitPool *netipx.IPSet) *Conn25 { + return &Conn25{ + magicIPPool: *newIPPool(magicPool), + transitIPPool: *newIPPool(transitPool), + } +} + +func (c *Conn25) assignMagic(domain string, addr netip.Addr) (netip.Addr, error) { + mip, err := c.magicIPPool.next() + if err != nil { + // TODO(fran) the pool is exhausted, what to do? + return netip.Addr{}, err + } + // TODO(fran) plumb this through from somewhere + nid := tailcfg.NodeID(1) + // TODO(fran) + app := "dunno? " + domain + c.setMagicIP(nid, mip, addr, app) + return mip, nil +} + +func (c *Conn25) MapDNSResponse(buf []byte) []byte { + // TODO(fran) should we be passing everything through (pretending we're not here) + // or eg putting our info in SOARecords? + // TODO(fran) does something a bit more general than this belong in the dns package somewhere? + // how similar is it to what we do in natc (not _super_ similar), or eg sniproxy, messagecache, peerapi + var msg dnsmessage.Message + err := msg.Unpack(buf) + if err != nil { + return buf + } + + var resolves map[string][]netip.Addr + var addrQCount int + for _, q := range msg.Questions { + if q.Type != dnsmessage.TypeA && q.Type != dnsmessage.TypeAAAA { + continue + } + addrQCount++ + } + + rcode := dnsmessage.RCodeSuccess + if addrQCount > 0 && len(resolves) == 0 { + rcode = dnsmessage.RCodeNameError + } + + b := dnsmessage.NewBuilder(nil, + dnsmessage.Header{ + ID: msg.Header.ID, + Response: true, + Authoritative: true, + RCode: rcode, + }) + b.EnableCompression() + + if err := b.StartQuestions(); err != nil { + return buf + } + + for _, q := range msg.Questions { + b.Question(q) + } + + if err := b.StartAnswers(); err != nil { + return buf + } + + for _, a := range msg.Answers { + switch a.Header.Type { + case dnsmessage.TypeA: + msgARecord := (a.Body).(*dnsmessage.AResource) + ourAddr, err := c.assignMagic(a.Header.Name.String(), netip.AddrFrom4(msgARecord.A)) + if err != nil { + return buf + } + if err := b.AResource( + a.Header, + dnsmessage.AResource{A: ourAddr.As4()}, + ); err != nil { + return buf + } + default: + // TODO how to just write whatever we already have? is this it? + body := a.Body.(*dnsmessage.UnknownResource) + b.UnknownResource(a.Header, *body) + } + } + + outbs, err := b.Finish() + if err != nil { + return buf + } + return outbs } const dupeTransitIPMessage = "Duplicate transit address in ConnectorTransitIPRequest" @@ -55,6 +165,20 @@ func (c *Conn25) handleTransitIPRequest(nid tailcfg.NodeID, tipr TransitIPReques return TransitIPResponse{} } +func (c *Conn25) setMagicIP(nid tailcfg.NodeID, magicAddr, dstAddr netip.Addr, app string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.magicIPs == nil { + c.magicIPs = make(map[tailcfg.NodeID]map[netip.Addr]appAddr) + } + peerMap, ok := c.magicIPs[nid] + if !ok { + peerMap = make(map[netip.Addr]appAddr) + c.magicIPs[nid] = peerMap + } + peerMap[magicAddr] = appAddr{addr: dstAddr, app: app} +} + func (c *Conn25) transitIPTarget(nid tailcfg.NodeID, tip netip.Addr) netip.Addr { c.mu.Lock() defer c.mu.Unlock() diff --git a/appc/conn25_test.go b/appc/conn25_test.go index ab6c4be37..e58124f0a 100644 --- a/appc/conn25_test.go +++ b/appc/conn25_test.go @@ -4,6 +4,7 @@ package appc import ( + "fmt" "net/netip" "testing" @@ -186,3 +187,10 @@ func TestTransitIPTargetUnknownTIP(t *testing.T) { t.Fatalf("Unknown transit addr, want: %v, got %v", want, got) } } + +func TestFran(t *testing.T) { + respbs := []byte{170, 165, 129, 128, 0, 1, 0, 1, 0, 0, 0, 0, 6, 103, 111, 111, 103, 108, 101, 3, 99, 111, 109, 0, 0, 1, 0, 1, 192, 12, 0, 1, 0, 1, 0, 0, 0, 15, 0, 4, 142, 250, 188, 238} + c := &Conn25{} + mappedBytes := c.MapDNSResponse(respbs) + fmt.Println(mappedBytes) +} diff --git a/feature/conn25/conn25.go b/feature/conn25/conn25.go index e7baca4bd..835ea4d4c 100644 --- a/feature/conn25/conn25.go +++ b/feature/conn25/conn25.go @@ -23,6 +23,7 @@ func init() { feature.Register(featureName) newExtension := func(logf logger.Logf, sb ipnext.SafeBackend) (ipnext.Extension, error) { e := &extension{ + // TODO(fran) 2025-12-18 we need to unify this with the conn25 in [ipnlocal.LocalBackend] conn: &appc.Conn25{}, } return e, nil diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 73fa56c18..f63030a84 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -88,6 +88,7 @@ import ( "tailscale.com/util/execqueue" "tailscale.com/util/goroutines" "tailscale.com/util/mak" + "tailscale.com/util/must" "tailscale.com/util/osuser" "tailscale.com/util/rands" "tailscale.com/util/set" @@ -271,6 +272,7 @@ type LocalBackend struct { ccGen clientGen // function for producing controlclient; lazily populated sshServer SSHServer // or nil, initialized lazily. appConnector *appc.AppConnector // or nil, initialized when configured. + conn25 *appc.Conn25 // or nil, initialized when configured. // notifyCancel cancels notifications to the current SetNotifyCallback. notifyCancel context.CancelFunc cc controlclient.Client // TODO(nickkhyl): move to nodeBackend @@ -4923,6 +4925,27 @@ func (b *LocalBackend) blockEngineUpdatesLocked(block bool) { b.blocked = block } +func (b *LocalBackend) reconfigConn25(nm *netmap.NetworkMap, prefs ipn.PrefsView) { + // TODO(fran) figure out if there's conn25ing happening, presumably if there's connectors in capmap and not like --accept-routes=false???? something? + // nb in contrast to appc, conn25 needs to keep state on the client too. + // TODO(fran) what happens when the profile changes? that's why we get called from authReconfig right? + // TODO(fran) this conn25 needs to be the same one in the extension in /feature/conn25 + if b.conn25 == nil { + // TODO debug code + mpoolbuilder := &netipx.IPSetBuilder{} + mpoolbuilder.AddPrefix(netip.MustParsePrefix("1.0.0.0/16")) + tpoolbuilder := &netipx.IPSetBuilder{} + tpoolbuilder.AddPrefix(netip.MustParsePrefix("2.0.0.0/16")) + b.conn25 = appc.NewConn25(must.Get(mpoolbuilder.IPSet()), must.Get(tpoolbuilder.IPSet())) + dnsManager, ok := b.sys.DNSManager.GetOK() + if ok { // TODO + dnsManager.QueryResponseMapper = func(inbs []byte) []byte { + return b.conn25.MapDNSResponse(inbs) + } + } + } +} + // reconfigAppConnectorLocked updates the app connector state based on the // current network map and preferences. // b.mu must be held. @@ -5065,6 +5088,7 @@ func (b *LocalBackend) authReconfigLocked() { dcfg := cn.dnsConfigForNetmap(prefs, b.keyExpired, version.OS()) // If the current node is an app connector, ensure the app connector machine is started b.reconfigAppConnectorLocked(nm, prefs) + b.reconfigConn25(nm, prefs) if !prefs.WantRunning() { b.logf("[v1] authReconfig: skipping because !WantRunning.") diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index efef57ea4..cd33050f8 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -841,6 +841,12 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. // Add split DNS routes, with no regard to exit node configuration. addSplitDNSRoutes(nm.DNS.Routes) + // TODO(fran) here's where we look for the capmap for conn25 + if nm.SelfName() == "d783302cc665.taile25f.ts.net." { + addSplitDNSRoutes(map[string][]*dnstype.Resolver{ + "google.com": {&dnstype.Resolver{Addr: "http://100.105.210.108:41811/dns-query"}}, + }) + } // Set FallbackResolvers as the default resolvers in the // scenarios that can't handle a purely split-DNS config. See diff --git a/net/dns/manager.go b/net/dns/manager.go index 4441c4f69..da9763344 100644 --- a/net/dns/manager.go +++ b/net/dns/manager.go @@ -67,6 +67,8 @@ type Manager struct { knobs *controlknobs.Knobs // or nil goos string // if empty, gets set to runtime.GOOS + QueryResponseMapper func(bs []byte) []byte + mu sync.Mutex // guards following config *Config // Tracks the last viable DNS configuration set by Set. nil on failures other than compilation failures or if set has never been called. } @@ -465,7 +467,15 @@ func (m *Manager) Query(ctx context.Context, bs []byte, family string, from neti return nil, errFullQueue } defer atomic.AddInt32(&m.activeQueriesAtomic, -1) - return m.resolver.Query(ctx, bs, family, from) + outbs, err := m.resolver.Query(ctx, bs, family, from) + if err == nil && m.QueryResponseMapper != nil { + outbs = m.QueryResponseMapper(outbs) + } + return outbs, err +} + +func (m *Manager) fran() { + } const (