Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/tailmix/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1686,7 +1686,7 @@ const profilesHelp = `Usage:
tailmix profiles remove <profile> [--purge --yes] [--json]

Add options:
--hostname <hostname>
--hostname <hostname> (default: machine hostname)
--state-dir <directory>
--auth-key-env <variable>
--auth-key-file <path|->
Expand Down
35 changes: 35 additions & 0 deletions cmd/tailmixd/hostname.go
Original file line number Diff line number Diff line change
@@ -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)
}
122 changes: 122 additions & 0 deletions cmd/tailmixd/hostname_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
43 changes: 42 additions & 1 deletion cmd/tailmixd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
120 changes: 119 additions & 1 deletion cmd/tailmixd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{{
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}
Loading