From 5b62f988945195c72ddb60bf8baa100f62900d68 Mon Sep 17 00:00:00 2001 From: Alex Chan Date: Mon, 23 Mar 2026 14:37:13 +0000 Subject: [PATCH] ipn, cmd/tailscale/cli: allow setting FQDN sans dot as an exit node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In #10057, @seigel pointed out an inconsistency in the help text for `exit-node list` and `set --exit-node`: 1. Use `tailscale exit-node list`, which has a column titled "hostname" and tells you that you can use a hostname with `set --exit-node`: ```console $ tailscale exit-node list IP HOSTNAME COUNTRY CITY STATUS 100.98.193.6 linode-vps.tailfa84dd.ts.net - - - […] 100.93.242.75 ua-iev-wg-001.mullvad.ts.net Ukraine Kyiv - # To view the complete list of exit nodes for a country, use `tailscale exit-node list --filter=` followed by the country name. # To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP. # To have Tailscale suggest an exit node, use `tailscale exit-node suggest`. ``` (This is the same format hostnames are presented in the admin console.) 2. Try copy/pasting a hostname into `set --exit-node`: ```console $ tailscale set --exit-node=linode-vps.tailfa84dd.ts.net invalid value "linode-vps.tailfa84dd.ts.net" for --exit-node; must be IP or unique node name ``` 3. Note that the command allows some hostnames, if they're from nodes in a different tailnet: ```console $ tailscale set --exit-node= ua-iev-wg-001.mullvad.ts.net $ echo $? 0 ``` This patch addresses the inconsistency in two ways: 1. Allow using `tailscale set --exit-node=` with an FQDN that's missing the trailing dot, matching the formatting used in `exit-node list` and the admin console. 2. Make the description of valid exit nodes consistent across commands ("hostname or IP"). Updates #10057 Change-Id: If5d74f950cc1a9cc4b0ebc0c2f2d70689ffe4d73 Signed-off-by: Alex Chan --- cmd/tailscale/cli/cli_test.go | 2 +- cmd/tailscale/cli/exitnode.go | 2 +- ipn/prefs.go | 15 ++++++++++++--- ipn/prefs_test.go | 19 +++++++++++++++++-- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index bdf9116a0..221bf55fa 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/cmd/tailscale/cli/cli_test.go @@ -769,7 +769,7 @@ func TestPrefsFromUpArgs(t *testing.T) { args: upArgsT{ exitNodeIP: "foo", }, - wantErr: `invalid value "foo" for --exit-node; must be IP or unique node name`, + wantErr: `invalid value "foo" for --exit-node; must be IP or hostname`, }, { name: "error_exit_node_allow_lan_without_exit_node", diff --git a/cmd/tailscale/cli/exitnode.go b/cmd/tailscale/cli/exitnode.go index 0445b66ae..7ba4859d7 100644 --- a/cmd/tailscale/cli/exitnode.go +++ b/cmd/tailscale/cli/exitnode.go @@ -138,7 +138,7 @@ func runExitNodeList(ctx context.Context, args []string) error { fmt.Fprintln(w) fmt.Fprintln(w) fmt.Fprintln(w, "# To view the complete list of exit nodes for a country, use `tailscale exit-node list --filter=` followed by the country name.") - fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP.") + fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the IP or hostname.") if hasAnyExitNodeSuggestions(peers) { fmt.Fprintln(w, "# To have Tailscale suggest an exit node, use `tailscale exit-node suggest`.") } diff --git a/ipn/prefs.go b/ipn/prefs.go index 1492bae38..9125df2c1 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -896,8 +896,17 @@ func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) { } match := 0 for _, ps := range st.Peer { - baseName := dnsname.TrimSuffix(ps.DNSName, st.MagicDNSSuffix) - if !strings.EqualFold(s, baseName) && !strings.EqualFold(s, ps.DNSName) { + // Compare to the peer name in three forms: + // + // - base name ("example") + // - FQDN ("example.tail1234.ts.net.") + // - FQDN sans dot ("example.tail1234.ts.net", as returned by `tailscale exit-node list` + // and the admin console) + // + fqdn := ps.DNSName + baseName := dnsname.TrimSuffix(fqdn, st.MagicDNSSuffix) + fqdnSansDot := dnsname.TrimSuffix(fqdn, ".") + if !strings.EqualFold(s, baseName) && !strings.EqualFold(s, fqdn) && !strings.EqualFold(s, fqdnSansDot) { continue } match++ @@ -911,7 +920,7 @@ func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) { } switch match { case 0: - return ip, fmt.Errorf("invalid value %q for --exit-node; must be IP or unique node name", s) + return ip, fmt.Errorf("invalid value %q for --exit-node; must be IP or hostname", s) case 1: if !isRemoteIP(st, ip) { return ip, ExitNodeLocalIPError{s} diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index 24c8f194e..e16a5b519 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -1009,7 +1009,7 @@ func TestExitNodeIPOfArg(t *testing.T) { name: "no_match", arg: "unknown", st: &ipnstate.Status{MagicDNSSuffix: ".foo"}, - wantErr: `invalid value "unknown" for --exit-node; must be IP or unique node name`, + wantErr: `invalid value "unknown" for --exit-node; must be IP or hostname`, }, { name: "name", @@ -1041,6 +1041,21 @@ func TestExitNodeIPOfArg(t *testing.T) { }, want: mustIP("1.0.0.2"), }, + { + name: "name_fqdn_sans_dot", + arg: "skippy.foo", + st: &ipnstate.Status{ + MagicDNSSuffix: ".foo", + Peer: map[key.NodePublic]*ipnstate.PeerStatus{ + key.NewNode().Public(): { + DNSName: "skippy.foo.", + TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")}, + ExitNodeOption: true, + }, + }, + }, + want: mustIP("1.0.0.2"), + }, { name: "name_not_exit", arg: "skippy", @@ -1067,7 +1082,7 @@ func TestExitNodeIPOfArg(t *testing.T) { }, }, }, - wantErr: `invalid value "skippy.bar." for --exit-node; must be IP or unique node name`, + wantErr: `invalid value "skippy.bar." for --exit-node; must be IP or hostname`, }, { name: "ambiguous",