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 3f01b405a..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) } @@ -893,6 +897,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 +910,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..12ff24a0b 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" @@ -67,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{{ @@ -104,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) } } @@ -900,3 +902,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..a5c673957 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) @@ -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/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..4ed78288b 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 @@ -489,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