From 32d950bd260bd1b23524258a21aabd65bf412bdb Mon Sep 17 00:00:00 2001 From: David Siewert Date: Thu, 5 Nov 2026 14:23:08 +0600 Subject: [PATCH 1/2] dns,cmd/tailmixd: translate split-DNS answers to effective IPs Split-DNS servers inside a tailnet can answer with a peer's canonical tailnet address, which the host cannot route since tailmix only routes effective IPs. Give each DNS route the canonical to effective mapping of its own profile and have the profile forwarders rewrite matching A and AAAA records in upstream answers, using the same leases and host NAT addresses as the MagicDNS records. Unmapped addresses, CNAMEs, and authority and additional records pass through untouched, and responses without rewrites keep their original bytes. Change-Id: I5089876d1aba81f4ef30163f986c3bbeafd16a59 --- cmd/tailmixd/main.go | 37 +++++ cmd/tailmixd/main_test.go | 117 +++++++++++++++ cmd/tailmixd/supervisor.go | 4 +- dns/forwarder.go | 62 +++++++- dns/forwarder_test.go | 297 ++++++++++++++++++++++++++++++++++++- dns/service.go | 18 +++ docs/design.md | 9 +- docs/profile-management.md | 8 +- 8 files changed, 532 insertions(+), 20 deletions(-) diff --git a/cmd/tailmixd/main.go b/cmd/tailmixd/main.go index 3f01b405a..9f92bc229 100644 --- a/cmd/tailmixd/main.go +++ b/cmd/tailmixd/main.go @@ -893,6 +893,7 @@ func tunDNSLiveConfig(st state.State, statuses []tailmixprofile.Status, leases [ routes = append(routes, route) } sort.Slice(routes, func(i, j int) bool { return routes[i].Suffix < routes[j].Suffix }) + attachSplitDNSRewrites(st, statuses, byKey, routes) searchDomains := make([]string, 0, len(policy.Search.Installed)) for _, domain := range policy.Search.Installed { searchDomains = append(searchDomains, domain.Domain) @@ -905,6 +906,42 @@ func tunDNSLiveConfig(st state.State, statuses []tailmixprofile.Status, leases [ }, nil } +// attachSplitDNSRewrites gives each route with a profile the canonical to +// effective IP mapping of that same profile, using the same leases and host +// NAT addresses as the MagicDNS records above. Forwarders translate split-DNS +// answers that point at a known node or service of the routing profile into +// its effective IP; addresses from other tailnets are never rewritten even +// when tailnets reuse the same canonical addresses. Routes without a profile +// keep a nil map. +func attachSplitDNSRewrites(st state.State, statuses []tailmixprofile.Status, byKey map[effectiveip.NodeKey]netip.Addr, routes []tailmixdns.Route) { + byProfile := make(map[string]map[netip.Addr]netip.Addr, len(statuses)) + for _, ps := range statuses { + rewrites := map[netip.Addr]netip.Addr{} + for _, canonical := range ps.SelfIPs { + if effective := natIPFor(st, canonical); effective.IsValid() { + rewrites[canonical] = effective + } + } + for _, target := range reachableTargets(ps) { + for _, canonical := range target.IPs { + key := effectiveip.NodeKey{ProfileID: ps.ProfileID, NodeID: target.ID, CanonicalIP: canonical} + if effective, ok := byKey[key]; ok && effective.IsValid() { + rewrites[canonical] = effective + } + } + } + if len(rewrites) > 0 { + byProfile[ps.ProfileID] = rewrites + } + } + for i := range routes { + if routes[i].ProfileID == "" { + continue + } + routes[i].Rewrites = byProfile[routes[i].ProfileID] + } +} + func hasDNSPolicyDomain(domain string, entries []routingpolicy.DNSEntry) bool { for _, entry := range entries { if entry.Domain == domain { diff --git a/cmd/tailmixd/main_test.go b/cmd/tailmixd/main_test.go index 3616edb84..bcfa975c9 100644 --- a/cmd/tailmixd/main_test.go +++ b/cmd/tailmixd/main_test.go @@ -12,6 +12,7 @@ import ( "github.com/maisem/tailmix/effectiveip" "github.com/maisem/tailmix/packetmap" tailmixprofile "github.com/maisem/tailmix/profile" + "github.com/maisem/tailmix/routingpolicy" "github.com/maisem/tailmix/state" tailmixversion "github.com/maisem/tailmix/version" "tailscale.com/net/packet" @@ -900,3 +901,119 @@ func hasDNSRecord(records []tailmixdns.Record, name string, ip netip.Addr) bool } return false } + +func TestTunDNSLiveConfigMapsSplitDNSRewritesPerProfile(t *testing.T) { + canonicalSelf := netip.MustParseAddr("100.64.0.10") + canonicalPeer := netip.MustParseAddr("100.64.0.20") + canonicalSvc := netip.MustParseAddr("100.100.1.10") + statusFor := func(profileID, suffix string) tailmixprofile.Status { + return tailmixprofile.Status{ + ProfileID: profileID, + MagicDNSSuffix: suffix, + SelfNodeID: profileID + "-self", + SelfDNSName: "tailmix." + suffix, + SelfIPs: []netip.Addr{canonicalSelf}, + Peers: []tailmixprofile.PeerStatus{{ + NodeID: profileID + "-peer", + DNSName: "db." + suffix, + TailscaleIPs: []netip.Addr{canonicalPeer}, + }}, + Services: []tailmixprofile.ServiceStatus{{ + Name: "svc:api", + DNSName: "api." + suffix, + TailscaleIPs: []netip.Addr{canonicalSvc}, + }}, + } + } + statuses := []tailmixprofile.Status{statusFor("home", "home.ts.net"), statusFor("work", "work.ts.net")} + st := state.State{ + SyntheticPool: "100.127.0.0/24", + SyntheticPoolV6: "fd6d:6e65:7400::/120", + } + if err := ensureHostIPs(&st); err != nil { + t.Fatal(err) + } + leases, _, err := assignEffectiveIPs(st, statuses) + if err != nil { + t.Fatal(err) + } + // Both tailnets reuse the same canonical addresses, so the per-route + // maps must select the routing profile's own effective IPs. + effective := map[string]map[netip.Addr]netip.Addr{} + for _, lease := range leases { + if effective[lease.NodeKey.ProfileID] == nil { + effective[lease.NodeKey.ProfileID] = map[netip.Addr]netip.Addr{} + } + effective[lease.NodeKey.ProfileID][lease.NodeKey.CanonicalIP] = lease.EffectiveIP + } + policy := routingpolicy.DNSPlan{ + Imported: []routingpolicy.DNSEntry{{ + Domain: "corp.example", + ProfileID: "work", + Active: true, + Resolvers: []*dnstype.Resolver{{Addr: "10.0.0.53"}}, + }}, + Automatic: []routingpolicy.DNSEntry{{ + Domain: "svc.example", + ProfileID: "work", + Active: true, + ProfileDNS: true, + }}, + } + cfg, err := tunDNSLiveConfig(st, statuses, leases, policy) + if err != nil { + t.Fatal(err) + } + routeBySuffix := map[string]tailmixdns.Route{} + for _, route := range cfg.Routes { + routeBySuffix[route.Suffix] = route + } + wantWork := map[netip.Addr]netip.Addr{ + canonicalSelf: st.NATIP, + canonicalPeer: effective["work"][canonicalPeer], + canonicalSvc: effective["work"][canonicalSvc], + } + for _, suffix := range []string{"corp.example", "svc.example"} { + route, ok := routeBySuffix[suffix] + if !ok { + t.Fatalf("route %q missing from %+v", suffix, cfg.Routes) + } + if route.ProfileID != "work" { + t.Fatalf("route %q has profile %q, want work", suffix, route.ProfileID) + } + if len(route.Rewrites) != len(wantWork) { + t.Fatalf("route %q rewrites = %v, want %v", suffix, route.Rewrites, wantWork) + } + for canonical, want := range wantWork { + if got := route.Rewrites[canonical]; got != want { + t.Fatalf("route %q rewrites[%v] = %v, want %v", suffix, canonical, got, want) + } + } + for _, homeIP := range effective["home"] { + for _, mapped := range route.Rewrites { + if mapped == homeIP { + t.Fatalf("route %q rewrites to home effective IP %v", suffix, homeIP) + } + } + } + } + profileDNS := routeBySuffix["svc.example"] + if !profileDNS.ProfileDNS { + t.Fatalf("svc.example route = %+v, want ProfileDNS", profileDNS) + } + // Routes without a profile (shared single-owner MagicDNS names) must + // not rewrite anything. + shared := 0 + for _, route := range cfg.Routes { + if route.ProfileID != "" { + continue + } + shared++ + if len(route.Rewrites) != 0 { + t.Fatalf("profile-less route %q has rewrites %v", route.Suffix, route.Rewrites) + } + } + if shared == 0 { + t.Fatalf("no profile-less routes in %+v", cfg.Routes) + } +} diff --git a/cmd/tailmixd/supervisor.go b/cmd/tailmixd/supervisor.go index e0749ae7f..9acc1d315 100644 --- a/cmd/tailmixd/supervisor.go +++ b/cmd/tailmixd/supervisor.go @@ -686,7 +686,7 @@ func (s *supervisor) configureDNSLocked(cfg tailmixdns.LiveConfig) error { closeForwarders(nextForwarders) return fmt.Errorf("profile %q does not expose its effective DNS resolver", s.nameForIDLocked(cfg.Routes[i].ProfileID)) } - forwarder, err := tailmixdns.StartProfileDNSForwarder(s.ctx, queryer) + forwarder, err := tailmixdns.StartProfileDNSForwarder(s.ctx, queryer, cfg.Routes[i].Rewrites) if err != nil { closeForwarders(nextForwarders) return fmt.Errorf("start profile %q effective DNS forwarder: %w", s.nameForIDLocked(cfg.Routes[i].ProfileID), err) @@ -703,7 +703,7 @@ func (s *supervisor) configureDNSLocked(cfg tailmixdns.LiveConfig) error { closeForwarders(nextForwarders) return fmt.Errorf("DNS route %q selected unavailable profile %q", cfg.Routes[i].Suffix, cfg.Routes[i].ProfileID) } - forwarder, err := tailmixdns.StartForwarder(s.ctx, managed.runtime.Engine, cfg.Routes[i].Resolvers) + forwarder, err := tailmixdns.StartForwarder(s.ctx, managed.runtime.Engine, cfg.Routes[i].Resolvers, cfg.Routes[i].Rewrites) if err != nil { closeForwarders(nextForwarders) return fmt.Errorf("start profile %q DNS forwarder: %w", s.nameForIDLocked(cfg.Routes[i].ProfileID), err) diff --git a/dns/forwarder.go b/dns/forwarder.go index 33eccfaa1..c711cfac9 100644 --- a/dns/forwarder.go +++ b/dns/forwarder.go @@ -43,6 +43,7 @@ type Forwarder struct { dialer ProfileDialer queryer ProfileDNSQueryer resolvers []*dnstype.Resolver + rewrites map[netip.Addr]netip.Addr udp *net.UDPConn tcp net.Listener addr netip.AddrPort @@ -55,26 +56,26 @@ type Forwarder struct { querySlots chan struct{} } -func StartForwarder(ctx context.Context, dialer ProfileDialer, resolvers []*dnstype.Resolver) (*Forwarder, error) { +func StartForwarder(ctx context.Context, dialer ProfileDialer, resolvers []*dnstype.Resolver, rewrites map[netip.Addr]netip.Addr) (*Forwarder, error) { if dialer == nil { return nil, errors.New("profile DNS forwarder requires a dialer") } if len(resolvers) == 0 { return nil, errors.New("profile DNS forwarder requires at least one resolver") } - return startForwarder(ctx, dialer, nil, resolvers) + return startForwarder(ctx, dialer, nil, resolvers, rewrites) } // StartProfileDNSForwarder exposes a loopback classic-DNS endpoint backed by a // profile's effective Tailscale DNS configuration. -func StartProfileDNSForwarder(ctx context.Context, queryer ProfileDNSQueryer) (*Forwarder, error) { +func StartProfileDNSForwarder(ctx context.Context, queryer ProfileDNSQueryer, rewrites map[netip.Addr]netip.Addr) (*Forwarder, error) { if queryer == nil { return nil, errors.New("profile DNS forwarder requires a queryer") } - return startForwarder(ctx, nil, queryer, nil) + return startForwarder(ctx, nil, queryer, nil, rewrites) } -func startForwarder(ctx context.Context, dialer ProfileDialer, queryer ProfileDNSQueryer, resolvers []*dnstype.Resolver) (*Forwarder, error) { +func startForwarder(ctx context.Context, dialer ProfileDialer, queryer ProfileDNSQueryer, resolvers []*dnstype.Resolver, rewrites map[netip.Addr]netip.Addr) (*Forwarder, error) { udp, tcp, err := listenForwarder(ctx, net.ListenUDP, net.ListenTCP) if err != nil { return nil, err @@ -87,6 +88,7 @@ func startForwarder(ctx context.Context, dialer ProfileDialer, queryer ProfileDN dialer: dialer, queryer: queryer, resolvers: cloneDNSResolvers(resolvers), + rewrites: cloneAddrMap(rewrites), udp: udp, tcp: tcp, conns: make(map[net.Conn]struct{}), @@ -332,6 +334,9 @@ func (f *Forwarder) exchange(ctx context.Context, network string, query []byte) if err == nil { err = validateResponse(query, response) } + if err == nil { + response, err = rewriteResponseAddrs(response, f.rewrites) + } return response, err } var lastErr error @@ -349,6 +354,9 @@ func (f *Forwarder) exchange(ctx context.Context, network string, query []byte) if err == nil { err = validateResponse(query, response) } + if err == nil { + response, err = rewriteResponseAddrs(response, f.rewrites) + } if err == nil { return response, nil } @@ -360,6 +368,50 @@ func (f *Forwarder) exchange(ctx context.Context, network string, query []byte) return nil, lastErr } +// rewriteResponseAddrs translates A and AAAA records in the Answer section of +// response whose address is a known canonical tailnet address into the +// effective IP for this host. rewrites carries exactly one profile's mapping, +// so addresses from other tailnets are never translated. Records in the +// authority and additional sections, CNAMEs, unmapped addresses, and +// family-mismatched mappings are left untouched. When nothing changes the +// original bytes are returned unmodified; when a change is needed but the +// response cannot be parsed or repacked an error is returned so the caller +// answers SERVFAIL instead of an unroutable address. +func rewriteResponseAddrs(response []byte, rewrites map[netip.Addr]netip.Addr) ([]byte, error) { + if len(rewrites) == 0 { + return response, nil + } + var msg dnsmessage.Message + if err := msg.Unpack(response); err != nil { + return nil, err + } + changed := false + for i := range msg.Answers { + switch body := msg.Answers[i].Body.(type) { + case *dnsmessage.AResource: + addr := netip.AddrFrom4(body.A) + if effective, ok := rewrites[addr]; ok && effective.Is4() && effective != addr { + body.A = effective.As4() + changed = true + } + case *dnsmessage.AAAAResource: + addr := netip.AddrFrom16(body.AAAA) + if effective, ok := rewrites[addr]; ok && effective.Is6() && effective != addr { + body.AAAA = effective.As16() + changed = true + } + } + } + if !changed { + return response, nil + } + packed, err := msg.Pack() + if err != nil { + return nil, err + } + return packed, nil +} + func (f *Forwarder) exchangeProfileDNS(ctx context.Context, query []byte) ([]byte, error) { if err := ctx.Err(); err != nil { return nil, err diff --git a/dns/forwarder_test.go b/dns/forwarder_test.go index 3ea357212..32baafa39 100644 --- a/dns/forwarder_test.go +++ b/dns/forwarder_test.go @@ -1,6 +1,7 @@ package dns import ( + "bytes" "context" "encoding/binary" "errors" @@ -352,7 +353,7 @@ func TestForwarderServfailForUpstreamErrorsAndInvalidResponses(t *testing.T) { case "not-response": response = testDNSMessage(t, false, dnsmessage.TypeAAAA) } - f, startErr := StartProfileDNSForwarder(context.Background(), functionQueryer(func(string, dnsmessage.Type) ([]byte, error) { return response, err })) + f, startErr := StartProfileDNSForwarder(context.Background(), functionQueryer(func(string, dnsmessage.Type) ([]byte, error) { return response, err }), nil) if startErr != nil { t.Fatal(startErr) } @@ -378,7 +379,7 @@ func TestForwarderRequestBudgetAndContextTrait(t *testing.T) { t.Error("legacy method called") return nil, errors.New("wrong method") }, query: func(ctx context.Context) ([]byte, error) { <-ctx.Done(); return nil, ctx.Err() }} - f, err := StartProfileDNSForwarder(context.Background(), q) + f, err := StartProfileDNSForwarder(context.Background(), q, nil) if err != nil { t.Fatal(err) } @@ -404,7 +405,7 @@ func TestForwarderRetireDrainsUDPAndTCP(t *testing.T) { entered, release := make(chan struct{}), make(chan struct{}) response := testDNSMessage(t, true, dnsmessage.TypeA) q := functionQueryer(func(string, dnsmessage.Type) ([]byte, error) { close(entered); <-release; return response, nil }) - f, err := StartProfileDNSForwarder(context.Background(), q) + f, err := StartProfileDNSForwarder(context.Background(), q, nil) if err != nil { t.Fatal(err) } @@ -426,7 +427,7 @@ func TestForwarderRetireDrainsUDPAndTCP(t *testing.T) { f.Retire() // Further generations retire independently, not by closing their predecessor. for i := 0; i < 2; i++ { - next, err := StartProfileDNSForwarder(context.Background(), q) + next, err := StartProfileDNSForwarder(context.Background(), q, nil) if err != nil { t.Fatal(err) } @@ -449,7 +450,7 @@ func TestForwarderRetireDrainsUDPAndTCP(t *testing.T) { func TestForwarderParentCancellationClosesIdleTCP(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - f, err := StartProfileDNSForwarder(ctx, functionQueryer(func(string, dnsmessage.Type) ([]byte, error) { return nil, errors.New("unused") })) + f, err := StartProfileDNSForwarder(ctx, functionQueryer(func(string, dnsmessage.Type) ([]byte, error) { return nil, errors.New("unused") }), nil) if err != nil { t.Fatal(err) } @@ -476,7 +477,7 @@ func TestForwarderLegacyWorkerBoundSurvivesTimeouts(t *testing.T) { finished <- struct{}{} return nil, errors.New("released") }) - f, err := StartProfileDNSForwarder(context.Background(), q) + f, err := StartProfileDNSForwarder(context.Background(), q, nil) if err != nil { t.Fatal(err) } @@ -563,7 +564,7 @@ func TestForwarderRetireClosesPartialTCPReads(t *testing.T) { f, err := StartProfileDNSForwarder(context.Background(), functionQueryer(func(string, dnsmessage.Type) ([]byte, error) { t.Error("partial query reached upstream") return nil, errors.New("unused") - })) + }), nil) if err != nil { t.Fatal(err) } @@ -581,3 +582,285 @@ func TestForwarderRetireClosesPartialTCPReads(t *testing.T) { t.Fatal("partial TCP read prevented retirement") } } + +func rewriteTestResource(name string, ttl uint32, typ dnsmessage.Type, body dnsmessage.ResourceBody) dnsmessage.Resource { + return dnsmessage.Resource{ + Header: dnsmessage.ResourceHeader{Name: dnsmessage.MustNewName(name), Type: typ, Class: dnsmessage.ClassINET, TTL: ttl}, + Body: body, + } +} + +func rewriteTestResponse(t *testing.T, typ dnsmessage.Type, answers, authorities, additionals []dnsmessage.Resource) []byte { + t.Helper() + msg := dnsmessage.Message{ + Header: dnsmessage.Header{ID: 0xbeef, Response: true, RecursionDesired: true, RecursionAvailable: true, RCode: dnsmessage.RCodeSuccess}, + Questions: []dnsmessage.Question{{Name: dnsmessage.MustNewName("host.example.com."), Type: typ, Class: dnsmessage.ClassINET}}, + Answers: answers, + Authorities: authorities, + Additionals: additionals, + } + wire, err := msg.Pack() + if err != nil { + t.Fatal(err) + } + return wire +} + +func rewriteTestAddrs() (canonicalV4, effectiveV4, canonicalV6, effectiveV6, foreignV4 netip.Addr) { + return netip.MustParseAddr("100.122.200.114"), + netip.MustParseAddr("100.127.0.11"), + netip.MustParseAddr("fd7a:115c:a1e0::20"), + netip.MustParseAddr("fd6d:6e65:7400::11"), + netip.MustParseAddr("100.99.0.5") +} + +func unpackTestResponse(t *testing.T, wire []byte) dnsmessage.Message { + t.Helper() + var msg dnsmessage.Message + if err := msg.Unpack(wire); err != nil { + t.Fatal(err) + } + return msg +} + +func answerV4(t *testing.T, msg dnsmessage.Message, index int) netip.Addr { + t.Helper() + body, ok := msg.Answers[index].Body.(*dnsmessage.AResource) + if !ok { + t.Fatalf("answer %d is %T, want A", index, msg.Answers[index].Body) + } + return netip.AddrFrom4(body.A) +} + +func TestRewriteResponseAddrs(t *testing.T) { + canonicalV4, effectiveV4, canonicalV6, effectiveV6, foreignV4 := rewriteTestAddrs() + rewrites := map[netip.Addr]netip.Addr{canonicalV4: effectiveV4, canonicalV6: effectiveV6} + aRecord := func(ip netip.Addr) dnsmessage.Resource { + return rewriteTestResource("host.example.com.", 60, dnsmessage.TypeA, &dnsmessage.AResource{A: ip.As4()}) + } + for _, tt := range []struct { + name string + typ dnsmessage.Type + answers []dnsmessage.Resource + authorities []dnsmessage.Resource + additionals []dnsmessage.Resource + rewrites map[netip.Addr]netip.Addr + wantV4 map[int]netip.Addr + wantV6 map[int]netip.Addr + wantIdentical bool + }{ + { + name: "A rewritten", + typ: dnsmessage.TypeA, + answers: []dnsmessage.Resource{aRecord(canonicalV4)}, + wantV4: map[int]netip.Addr{0: effectiveV4}, + }, + { + name: "AAAA rewritten", + typ: dnsmessage.TypeAAAA, + answers: []dnsmessage.Resource{rewriteTestResource("host.example.com.", 60, dnsmessage.TypeAAAA, &dnsmessage.AAAAResource{AAAA: canonicalV6.As16()})}, + wantV6: map[int]netip.Addr{0: effectiveV6}, + }, + { + name: "unmapped untouched", + typ: dnsmessage.TypeA, + answers: []dnsmessage.Resource{aRecord(netip.MustParseAddr("93.184.216.34"))}, + wantV4: map[int]netip.Addr{0: netip.MustParseAddr("93.184.216.34")}, + wantIdentical: true, + }, + { + // The map carries exactly one profile's leases. An address + // that is canonical in another tailnet but absent here must + // pass through even though it looks like a tailnet address. + name: "other profile address untouched", + typ: dnsmessage.TypeA, + answers: []dnsmessage.Resource{aRecord(foreignV4)}, + wantV4: map[int]netip.Addr{0: foreignV4}, + wantIdentical: true, + }, + { + name: "CNAME chain keeps alias and rewrites target", + typ: dnsmessage.TypeA, + answers: []dnsmessage.Resource{ + rewriteTestResource("alias.example.com.", 60, dnsmessage.TypeCNAME, &dnsmessage.CNAMEResource{CNAME: dnsmessage.MustNewName("host.example.com.")}), + aRecord(canonicalV4), + }, + wantV4: map[int]netip.Addr{1: effectiveV4}, + }, + { + // Only the Answer section is translated; authority and + // additional records keep their upstream addresses. + name: "authority and additional untouched", + typ: dnsmessage.TypeA, + authorities: []dnsmessage.Resource{aRecord(canonicalV4)}, + additionals: []dnsmessage.Resource{aRecord(canonicalV4)}, + wantIdentical: true, + }, + { + // A mapping across address families must never corrupt a record. + name: "family mismatch skipped", + typ: dnsmessage.TypeA, + answers: []dnsmessage.Resource{aRecord(canonicalV4)}, + rewrites: map[netip.Addr]netip.Addr{canonicalV4: effectiveV6}, + wantV4: map[int]netip.Addr{0: canonicalV4}, + wantIdentical: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + wire := rewriteTestResponse(t, tt.typ, tt.answers, tt.authorities, tt.additionals) + rewrites := rewrites + if tt.rewrites != nil { + rewrites = tt.rewrites + } + got, err := rewriteResponseAddrs(wire, rewrites) + if err != nil { + t.Fatal(err) + } + if tt.wantIdentical && !bytes.Equal(got, wire) { + t.Fatal("response without rewrites was repacked") + } + if !tt.wantIdentical && bytes.Equal(got, wire) { + t.Fatal("rewritten response is byte-identical to upstream") + } + msg := unpackTestResponse(t, got) + if msg.Header.ID != 0xbeef || !msg.Header.Response || msg.Header.RCode != dnsmessage.RCodeSuccess || len(msg.Questions) != 1 || msg.Questions[0].Type != tt.typ { + t.Fatalf("question/flags changed: %+v", msg) + } + for i, want := range tt.wantV4 { + if got := answerV4(t, msg, i); got != want { + t.Fatalf("answer %d = %v, want %v", i, got, want) + } + if ttl := msg.Answers[i].Header.TTL; ttl != 60 { + t.Fatalf("answer %d TTL = %d, want preserved 60", i, ttl) + } + } + for i, want := range tt.wantV6 { + body, ok := msg.Answers[i].Body.(*dnsmessage.AAAAResource) + if !ok { + t.Fatalf("answer %d is %T, want AAAA", i, msg.Answers[i].Body) + } + if got := netip.AddrFrom16(body.AAAA); got != want { + t.Fatalf("answer %d = %v, want %v", i, got, want) + } + } + if tt.name == "CNAME chain keeps alias and rewrites target" { + body, ok := msg.Answers[0].Body.(*dnsmessage.CNAMEResource) + if !ok || body.CNAME.String() != "host.example.com." { + t.Fatalf("CNAME answer changed: %+v", msg.Answers[0]) + } + if got := answerV4(t, msg, 1); got != effectiveV4 { + t.Fatalf("target A = %v, want %v", got, effectiveV4) + } + } + }) + } +} + +func TestRewriteResponseAddrsFailures(t *testing.T) { + canonicalV4, effectiveV4, _, _, _ := rewriteTestAddrs() + rewrites := map[netip.Addr]netip.Addr{canonicalV4: effectiveV4} + if _, err := rewriteResponseAddrs([]byte{1, 2}, rewrites); err == nil { + t.Fatal("unparseable response did not fail") + } + if got, err := rewriteResponseAddrs([]byte{1, 2}, nil); err != nil || string(got) != string([]byte{1, 2}) { + t.Fatalf("empty map did not pass bytes through: %x, %v", got, err) + } +} + +func TestForwarderRewritesProfileDNSAnswers(t *testing.T) { + canonicalV4, effectiveV4, _, _, _ := rewriteTestAddrs() + rewrites := map[netip.Addr]netip.Addr{canonicalV4: effectiveV4} + upstream := testDNSMessage(t, true, dnsmessage.TypeA) + var base dnsmessage.Message + if err := base.Unpack(upstream); err != nil { + t.Fatal(err) + } + base.Answers = []dnsmessage.Resource{ + rewriteTestResource("host.example.com.", 60, dnsmessage.TypeA, &dnsmessage.AResource{A: canonicalV4.As4()}), + } + response, err := base.Pack() + if err != nil { + t.Fatal(err) + } + f, err := StartProfileDNSForwarder(context.Background(), functionQueryer(func(string, dnsmessage.Type) ([]byte, error) { return response, nil }), rewrites) + if err != nil { + t.Fatal(err) + } + defer f.Close() + for _, network := range []string{"udp", "tcp"} { + conn := testDNSConn(t, f, network) + // The canned response carries the same question as the query, so + // validation accepts it on any name. + query := testDNSMessage(t, false, dnsmessage.TypeA) + writeDNSQuery(t, conn, network, query) + msg := readDNSReply(t, conn, network) + var body *dnsmessage.AResource + for i := range msg.Answers { + if body == nil { + body, _ = msg.Answers[i].Body.(*dnsmessage.AResource) + } + } + if body == nil { + t.Fatalf("%s: no A answer in %+v", network, msg) + } + if got := netip.AddrFrom4(body.A); got != effectiveV4 { + t.Fatalf("%s: answer = %v, want effective %v", network, got, effectiveV4) + } + } +} + +type directDNSDialer struct{} + +func (directDNSDialer) Dial(ctx context.Context, network, address string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, address) +} + +func TestForwarderRewritesClassicSplitDNSAnswers(t *testing.T) { + canonicalV4, effectiveV4, _, _, _ := rewriteTestAddrs() + rewrites := map[netip.Addr]netip.Addr{canonicalV4: effectiveV4} + upstream, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer upstream.Close() + go func() { + buffer := make([]byte, 64<<10) + for { + n, peer, err := upstream.ReadFromUDP(buffer) + if err != nil { + return + } + var query dnsmessage.Message + if err := query.Unpack(buffer[:n]); err != nil { + continue + } + response, err := (&dnsmessage.Message{ + Header: dnsmessage.Header{ID: query.Header.ID, Response: true, RecursionAvailable: true, RCode: dnsmessage.RCodeSuccess}, + Questions: query.Questions, + Answers: []dnsmessage.Resource{ + rewriteTestResource("host.example.com.", 60, dnsmessage.TypeA, &dnsmessage.AResource{A: canonicalV4.As4()}), + }, + }).Pack() + if err != nil { + continue + } + _, _ = upstream.WriteToUDP(response, peer) + } + }() + f, err := StartForwarder(context.Background(), directDNSDialer{}, []*dnstype.Resolver{{Addr: upstream.LocalAddr().String()}}, rewrites) + if err != nil { + t.Fatal(err) + } + defer f.Close() + conn := testDNSConn(t, f, "udp") + // The fake upstream echoes the query question, so validation accepts + // the canned response on any name. + writeDNSQuery(t, conn, "udp", testDNSMessage(t, false, dnsmessage.TypeA)) + msg := readDNSReply(t, conn, "udp") + if len(msg.Answers) != 1 { + t.Fatalf("answers = %+v, want one rewritten A", msg.Answers) + } + if got := answerV4(t, msg, 0); got != effectiveV4 { + t.Fatalf("answer = %v, want effective %v", got, effectiveV4) + } +} diff --git a/dns/service.go b/dns/service.go index 3a47f5706..f7242766e 100644 --- a/dns/service.go +++ b/dns/service.go @@ -24,6 +24,13 @@ type Route struct { ProfileID string ProfileDNS bool Resolvers []*dnstype.Resolver + // Rewrites maps a canonical tailnet address of a node or service of + // ProfileID to its effective IP for this host. Profile forwarders use + // it to translate split-DNS answers that point at tailnet addresses + // into routable effective IPs. Entries are strictly per profile: + // different tailnets may reuse the same canonical address. Nil when + // the route has no profile or no known addresses. + Rewrites map[netip.Addr]netip.Addr } type LiveConfig struct { @@ -191,6 +198,17 @@ func sameResolvers(a, b []*dnstype.Resolver) bool { return true } +func cloneAddrMap(in map[netip.Addr]netip.Addr) map[netip.Addr]netip.Addr { + if len(in) == 0 { + return nil + } + out := make(map[netip.Addr]netip.Addr, len(in)) + for canonical, effective := range in { + out[canonical] = effective + } + return out +} + func cloneDNSResolvers(in []*dnstype.Resolver) []*dnstype.Resolver { out := make([]*dnstype.Resolver, 0, len(in)) for _, resolver := range in { diff --git a/docs/design.md b/docs/design.md index be6a4a127..c4b01202d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -208,9 +208,12 @@ Rules: - Configured domains without a current DNS route remain desired but are not installed. - DNS answers for node and Tailscale Service names return effective IPs. -- Addresses returned by split DNS follow ordinary host IP routing. A - destination that must traverse a tailnet needs a separate IP route policy; - DNS bindings do not create application or app-connector routes. +- Split-DNS answers that point at a known node or service of the routing + profile are translated to its effective IP, so the host receives a routable + address. Other addresses returned by split DNS follow ordinary host IP + routing. A destination that must traverse a tailnet needs a separate IP + route policy; DNS bindings do not create application or app-connector + routes. Expected DNS-shaped names include upstream-style fully qualified names: diff --git a/docs/profile-management.md b/docs/profile-management.md index f1d1a3059..8618ca370 100644 --- a/docs/profile-management.md +++ b/docs/profile-management.md @@ -386,9 +386,11 @@ and DNS-over-HTTPS behavior rather than assuming every resolver is a plain UDP address. This policy routes DNS queries, not arbitrary application connections by -hostname. MagicDNS node answers contain effective IPs. Addresses returned by -split DNS follow the host's IP routing policy; reaching one through a tailnet -requires a corresponding installed IP route binding or accept-all import. +hostname. MagicDNS node answers contain effective IPs. Split-DNS answers +that point at a known node or service of the routing profile are translated +to its effective IP. Other addresses returned by split DNS follow the host's +IP routing policy; reaching one through a tailnet requires a corresponding +installed IP route binding or accept-all import. ### DNS search domains From ea87e8a9a27d29860f9a9637858f33545d968643 Mon Sep 17 00:00:00 2001 From: David Siewert Date: Thu, 5 Nov 2026 17:27:02 +0600 Subject: [PATCH 2/2] cmd/tailmixd: default new profile hostnames to the machine hostname New profiles previously advertised a random tailmix- name. Use the sanitized first label of the OS hostname instead, matching upstream Tailscale, and keep the generated name only as a fallback. Update the profiles add help and profile-management docs, and cover the default plus the fallback in tests. --- cmd/tailmix/main.go | 2 +- cmd/tailmixd/hostname.go | 35 ++++++++++ cmd/tailmixd/hostname_test.go | 122 ++++++++++++++++++++++++++++++++++ cmd/tailmixd/main.go | 6 +- cmd/tailmixd/main_test.go | 3 +- cmd/tailmixd/supervisor.go | 3 + docs/profile-management.md | 6 +- 7 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 cmd/tailmixd/hostname.go create mode 100644 cmd/tailmixd/hostname_test.go diff --git a/cmd/tailmix/main.go b/cmd/tailmix/main.go index 8c963854b..87d346216 100644 --- a/cmd/tailmix/main.go +++ b/cmd/tailmix/main.go @@ -1686,7 +1686,7 @@ const profilesHelp = `Usage: tailmix profiles remove [--purge --yes] [--json] Add options: - --hostname + --hostname (default: machine hostname) --state-dir --auth-key-env --auth-key-file diff --git a/cmd/tailmixd/hostname.go b/cmd/tailmixd/hostname.go new file mode 100644 index 000000000..caa53fc13 --- /dev/null +++ b/cmd/tailmixd/hostname.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + "strings" + + "tailscale.com/util/dnsname" +) + +// osHostname reports the machine's hostname. It is a variable so tests can +// stub the host name without changing the OS. +var osHostname = os.Hostname + +// sanitizeHostname converts a raw OS hostname into the short, lower-case DNS +// label tailmix advertises to a tailnet. It keeps only the first DNS label so +// an FQDN such as "laptop.example.com" becomes "laptop" rather than +// "laptop-example-com", then applies Tailscale's hostname sanitization. +func sanitizeHostname(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + return dnsname.SanitizeHostname(dnsname.FirstLabel(trimmed)) +} + +// defaultHostname returns the sanitized machine hostname, or empty when it is +// unavailable or sanitizes to nothing. Callers fall back to a generated +// tailmix- name in that case. +func defaultHostname() string { + name, err := osHostname() + if err != nil { + return "" + } + return sanitizeHostname(name) +} diff --git a/cmd/tailmixd/hostname_test.go b/cmd/tailmixd/hostname_test.go new file mode 100644 index 000000000..63fb7eacf --- /dev/null +++ b/cmd/tailmixd/hostname_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/maisem/tailmix/controlapi" + "github.com/maisem/tailmix/state" +) + +func newAddProfileRequest(name string, disabled bool) controlapi.AddProfileRequest { + return controlapi.AddProfileRequest{Name: name, Disabled: disabled} +} + +func testContext(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + return ctx +} + +func stubHostname(t *testing.T, name string, err error) { + t.Helper() + old := osHostname + osHostname = func() (string, error) { return name, err } + t.Cleanup(func() { osHostname = old }) +} + +func TestSanitizeHostname(t *testing.T) { + for _, test := range []struct{ in, want string }{ + {"david-laptop-fedora", "david-laptop-fedora"}, + {"MyHost", "myhost"}, + {"laptop.example.com", "laptop"}, + {" LAPTOP.local ", "laptop"}, + {"My_Host", "my-host"}, + {"", ""}, + {" ", ""}, + {"!!!", ""}, + {"a...b", "a"}, + } { + if got := sanitizeHostname(test.in); got != test.want { + t.Errorf("sanitizeHostname(%q) = %q, want %q", test.in, got, test.want) + } + } +} + +func TestDefaultHostnameUsesOSHostname(t *testing.T) { + stubHostname(t, "TestBox.Example.COM", nil) + if got, want := defaultHostname(), "testbox"; got != want { + t.Fatalf("defaultHostname() = %q, want %q", got, want) + } +} + +func TestDefaultHostnameEmptyWhenUnavailable(t *testing.T) { + stubHostname(t, "", errors.New("no hostname")) + if got := defaultHostname(); got != "" { + t.Fatalf("defaultHostname() = %q, want empty on error", got) + } + stubHostname(t, "!!!", nil) + if got := defaultHostname(); got != "" { + t.Fatalf("defaultHostname() = %q, want empty when unsanitizable", got) + } +} + +func TestResolveProfilesDefaultsHostnameToOSHostname(t *testing.T) { + stubHostname(t, "TestHost.example.com", nil) + statePath := filepath.Join(t.TempDir(), "state.json") + got, err := resolveProfiles(state.State{}, []profileSpec{{ID: "home"}}, statePath) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("profile count = %d, want 1", len(got)) + } + if got[0].State.Hostname != "testhost" { + t.Fatalf("hostname = %q, want %q", got[0].State.Hostname, "testhost") + } +} + +func TestResolveProfilesFallsBackWhenOSHostnameUnavailable(t *testing.T) { + stubHostname(t, "", errors.New("no hostname")) + statePath := filepath.Join(t.TempDir(), "state.json") + got, err := resolveProfiles(state.State{}, []profileSpec{{ID: "home"}}, statePath) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("profile count = %d, want 1", len(got)) + } + if got[0].State.Hostname != "tailmix-home" { + t.Fatalf("hostname = %q, want %q", got[0].State.Hostname, "tailmix-home") + } +} + +func TestAddProfileDefaultsHostnameToOSHostname(t *testing.T) { + stubHostname(t, "MyLaptop.example.com", nil) + s := newLifecycleTestSupervisor(t, lifecycleTestState()) + got, err := s.AddProfile(testContext(t), newAddProfileRequest("lab", true)) + if err != nil { + t.Fatal(err) + } + if got.Hostname != "mylaptop" { + t.Fatalf("hostname = %q, want %q", got.Hostname, "mylaptop") + } +} + +func TestAddProfileFallsBackWhenOSHostnameUnavailable(t *testing.T) { + stubHostname(t, "", errors.New("no hostname")) + s := newLifecycleTestSupervisor(t, lifecycleTestState()) + got, err := s.AddProfile(testContext(t), newAddProfileRequest("lab", true)) + if err != nil { + t.Fatal(err) + } + if got.Hostname == "" || got.Hostname == "mylaptop" { + t.Fatalf("hostname = %q, want generated fallback", got.Hostname) + } + if len(got.Hostname) < len("tailmix-")+1 || got.Hostname[:8] != "tailmix-" { + t.Fatalf("hostname = %q, want tailmix- prefix", got.Hostname) + } +} diff --git a/cmd/tailmixd/main.go b/cmd/tailmixd/main.go index 9f92bc229..83a26d952 100644 --- a/cmd/tailmixd/main.go +++ b/cmd/tailmixd/main.go @@ -464,7 +464,11 @@ func resolveProfiles(st state.State, specs []profileSpec, statePath string) ([]r rp.State.StateDir = filepath.Join(baseDir, rp.State.ID) } if rp.State.Hostname == "" { - rp.State.Hostname = "tailmix-" + dnsLabel(rp.State.ID) + if host := defaultHostname(); host != "" { + rp.State.Hostname = host + } else { + rp.State.Hostname = "tailmix-" + dnsLabel(rp.State.ID) + } } out = append(out, rp) } diff --git a/cmd/tailmixd/main_test.go b/cmd/tailmixd/main_test.go index bcfa975c9..12ff24a0b 100644 --- a/cmd/tailmixd/main_test.go +++ b/cmd/tailmixd/main_test.go @@ -68,6 +68,7 @@ func TestRegisterProfileFlagsAcceptsLongAndShortOptions(t *testing.T) { } func TestResolveProfilesMergesFlagsWithPersistentState(t *testing.T) { + stubHostname(t, "TestHost.example.com", nil) statePath := filepath.Join(t.TempDir(), "state.json") got, err := resolveProfiles(state.State{ Profiles: []state.Profile{{ @@ -105,7 +106,7 @@ func TestResolveProfilesMergesFlagsWithPersistentState(t *testing.T) { if home.State.ID != "home" { home = got[1] } - if home.State.Alias != "home" || home.State.StateDir == "" || home.State.Hostname != "tailmix-home" { + if home.State.Alias != "home" || home.State.StateDir == "" || home.State.Hostname != "testhost" { t.Fatalf("defaulted home profile = %+v", home.State) } } diff --git a/cmd/tailmixd/supervisor.go b/cmd/tailmixd/supervisor.go index 9acc1d315..a5c673957 100644 --- a/cmd/tailmixd/supervisor.go +++ b/cmd/tailmixd/supervisor.go @@ -1007,6 +1007,9 @@ func (s *supervisor) AddProfile(_ context.Context, request controlapi.AddProfile return controlapi.Profile{}, err } hostname := strings.TrimSpace(request.Hostname) + if hostname == "" { + hostname = defaultHostname() + } if hostname == "" { suffix, err := randomOpaque("", 5) if err != nil { diff --git a/docs/profile-management.md b/docs/profile-management.md index 8618ca370..4ed78288b 100644 --- a/docs/profile-management.md +++ b/docs/profile-management.md @@ -491,8 +491,10 @@ Defaults are: - The daemon generates a stable opaque profile ID. - State directory defaults to a persisted path under `/profiles` derived from that ID. -- Hostname defaults to an independently generated, persisted DNS hostname. It - is not derived from the profile name. +- Hostname defaults to the machine's hostname (first DNS label, sanitized to + a valid Tailscale name, e.g. `laptop` from `laptop.example.com`). It falls + back to an independently generated, persisted `tailmix-` name only when the + machine hostname is unavailable. It is not derived from the profile name. - The profile is enabled unless `--disabled` is supplied. The CLI, not the daemon, resolves `--auth-key-env`. `--auth-key-file -` reads