From 446ae9749121041349bd89f7f2383ab8a7441220 Mon Sep 17 00:00:00 2001 From: Alex Chan Date: Thu, 28 May 2026 14:15:40 +0100 Subject: [PATCH] ipn: improve --exit-node hostname error during startup When parsing the `tailscale up --exit-node=ARG` argument, we try to resolve hostnames by searching the list of peers. However, at startup, the peer list is empty, causing hostname lookups to trivially fail with an unhelpful "invalid value" erorr. Improve the error message when the peer list is empty to inform the user that hostnames cannot be resolved during startup, and advise them to use the exit node's Tailscale IP address instead. Also, clarify that hostnames must be peer hostnames, not arbitrary hostnames. Fixes #19882 Change-Id: I9390a427c2863d657cf46c5e33b43cb3c5363764 Signed-off-by: Alex Chan --- cmd/tailscale/cli/cli_test.go | 18 +++++++++++++++++- ipn/prefs.go | 13 ++++++++++++- ipn/prefs_test.go | 25 ++++++++++++++++++++----- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index d2df825d3..f60707ca0 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/cmd/tailscale/cli/cli_test.go @@ -30,6 +30,7 @@ import ( "tailscale.com/tka" "tailscale.com/tstest" "tailscale.com/tstest/deptest" + "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/opt" "tailscale.com/types/persist" @@ -769,7 +770,22 @@ func TestPrefsFromUpArgs(t *testing.T) { args: upArgsT{ exitNodeIP: "foo", }, - wantErr: `invalid value "foo" for --exit-node; must be IP or hostname`, + st: &ipnstate.Status{ + Peer: map[key.NodePublic]*ipnstate.PeerStatus{ + key.NewNode().Public(): { + DNSName: "example.com.", + TailscaleIPs: []netip.Addr{netip.MustParseAddr("1.0.0.2")}, + }, + }, + }, + wantErr: `invalid value "foo" for --exit-node; must be IP or peer hostname`, + }, + { + name: "error_exit_node_not_started", + args: upArgsT{ + exitNodeIP: "foo", + }, + wantErr: `cannot resolve exit node by hostname while Tailscale is starting up; please use its Tailscale IP address instead`, }, { name: "error_exit_node_allow_lan_without_exit_node", diff --git a/ipn/prefs.go b/ipn/prefs.go index 9125df2c1..e54086b61 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -872,10 +872,14 @@ func (e ExitNodeLocalIPError) Error() string { return fmt.Sprintf("cannot use %s as an exit node as it is a local IP address to this machine", e.hostOrIP) } +// exitNodeIPOfArg returns the IP address of the exit node based on the +// user-provided string. func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) { if s == "" { return ip, os.ErrInvalid } + + // If the string is a valid IP address, that's the exit node. ip, err = netip.ParseAddr(s) if err == nil { if !isRemoteIP(st, ip) { @@ -894,6 +898,13 @@ func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) { } return ip, nil } + + // If the string is not a valid IP address, assume it's a hostname. + // Search the list of peers for a matching hostname. + if len(st.Peer) == 0 { + return ip, errors.New("cannot resolve exit node by hostname while Tailscale is starting up; " + + "please use its Tailscale IP address instead") + } match := 0 for _, ps := range st.Peer { // Compare to the peer name in three forms: @@ -920,7 +931,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 hostname", s) + return ip, fmt.Errorf("invalid value %q for --exit-node; must be IP or peer 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 31dd2c55a..c20de0c00 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -1006,10 +1006,19 @@ func TestExitNodeIPOfArg(t *testing.T) { want: mustIP("1.2.3.4"), }, { - name: "no_match", - arg: "unknown", - st: &ipnstate.Status{MagicDNSSuffix: ".foo"}, - wantErr: `invalid value "unknown" for --exit-node; must be IP or hostname`, + name: "no_match", + arg: "unknown", + 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, + }, + }, + }, + wantErr: `invalid value "unknown" for --exit-node; must be IP or peer hostname`, }, { name: "name", @@ -1056,6 +1065,12 @@ func TestExitNodeIPOfArg(t *testing.T) { }, want: mustIP("1.0.0.2"), }, + { + name: "hostname_no_peer", + arg: "skippy.foo", + st: &ipnstate.Status{}, + wantErr: `cannot resolve exit node by hostname while Tailscale is starting up; please use its Tailscale IP address instead`, + }, { name: "name_not_exit", arg: "skippy", @@ -1082,7 +1097,7 @@ func TestExitNodeIPOfArg(t *testing.T) { }, }, }, - wantErr: `invalid value "skippy.bar." for --exit-node; must be IP or hostname`, + wantErr: `invalid value "skippy.bar." for --exit-node; must be IP or peer hostname`, }, { name: "ambiguous",