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",